packages feed

hledger-flow 0.19.0 → 0.20.0

raw patch · 43 files changed

+1267/−904 lines, 43 filesdep ~aesondep ~asyncdep ~bytestringPVP ok

version bump matches the API change (PVP)

Dependency ranges changed: aeson, async, bytestring

API changes (from Hackage documentation)

- Hledger.Flow.BaseDir: turtleBaseDir :: HasBaseDir o => o -> FilePath
- Hledger.Flow.Common: groupPairs :: (Eq a, Ord a) => [(a, b)] -> Map a [b]
- Hledger.Flow.Common: groupPairs' :: (Eq a, Ord a) => [(a, b)] -> [(a, [b])]
- Hledger.Flow.Common: pairBy :: (a -> b) -> [a] -> [(b, a)]
- Hledger.Flow.Import.ImportHelpersTurtle: extraIncludesForFile :: (HasVerbosity o, HasBaseDir o) => o -> TChan LogMessage -> FilePath -> [Text] -> [FilePath] -> [FilePath] -> IO TurtleFileBundle
- Hledger.Flow.Import.ImportHelpersTurtle: includePreamble :: Text
- Hledger.Flow.Import.ImportHelpersTurtle: includesNeedUpdate :: BaseDir -> AbsDir -> [AbsFile] -> [AbsFile] -> IO Bool
- Hledger.Flow.Import.ImportHelpersTurtle: toIncludeFiles :: (HasBaseDir o, HasVerbosity o) => o -> TChan LogMessage -> TurtleFileBundle -> IO (Map FilePath Text)
- Hledger.Flow.Import.ImportHelpersTurtle: toIncludeLine :: FilePath -> FilePath -> Text
- Hledger.Flow.Import.ImportHelpersTurtle: writeIncludesFromInventory :: (HasBaseDir o, HasVerbosity o) => o -> TChan LogMessage -> AbsDir -> [AbsFile] -> [AbsFile] -> IO [AbsFile]
- Hledger.Flow.Import.ImportHelpersTurtle: writeToplevelAllYearsInclude :: (HasBaseDir o, HasVerbosity o) => o -> IO [FilePath]
- Hledger.Flow.Import.ImportHelpersTurtle: yearsIncludeMap :: [FilePath] -> TurtleFileBundle
- Hledger.Flow.Import.Inventory: expectedOutputs :: [AbsFile] -> IO (Map AbsFile [AbsFile])
- Hledger.Flow.Import.Types: type TurtleFileBundle = Map FilePath [FilePath]
- Hledger.Flow.PathHelpers: InvalidTurtleDir :: FilePath -> PathException
- Hledger.Flow.PathHelpers: fromTurtleAbsDir :: MonadThrow m => FilePath -> m AbsDir
- Hledger.Flow.PathHelpers: fromTurtleAbsFile :: MonadThrow m => FilePath -> m AbsFile
- Hledger.Flow.PathHelpers: fromTurtleRelDir :: MonadThrow m => FilePath -> m RelDir
- Hledger.Flow.PathHelpers: fromTurtleRelFile :: MonadThrow m => FilePath -> m RelFile
- Hledger.Flow.PathHelpers: pathToTurtle :: Path b t -> FilePath
- Hledger.Flow.PathHelpers: turtleToAbsDir :: (MonadIO m, MonadThrow m) => AbsDir -> FilePath -> m AbsDir
- Hledger.Flow.RuntimeOptions: [hledgerInfo] :: RuntimeOptions -> HledgerInfo
- Hledger.Flow.RuntimeOptions: instance GHC.Internal.Show.Show Hledger.Flow.RuntimeOptions.RuntimeOptions
- Hledger.Flow.Status.Render: tildePath :: Maybe AbsDir -> FilePath -> FilePath
+ Hledger.Flow.BaseDir: baseDirFilePath :: HasBaseDir o => o -> FilePath
+ Hledger.Flow.Common: belowHome :: [FilePath] -> FilePath -> Maybe [FilePath]
+ Hledger.Flow.Common: deferredHledgerVersion :: AbsFile -> IO (IO Text)
+ Hledger.Flow.Common: findHledgerConf :: Maybe FilePath -> IO (Maybe HledgerConf)
+ Hledger.Flow.Common: hledgerFileArgument :: HledgerInfo -> FilePath -> Text
+ Hledger.Flow.Common: hledgerVersionNumber :: Text -> Text
+ Hledger.Flow.Common: homeDirs :: IO [FilePath]
+ Hledger.Flow.Common: homeShellPath :: Shell -> [FilePath] -> FilePath -> Maybe Text
+ Hledger.Flow.Common: probeHledgerVersion :: AbsFile -> IO (ExitCode, Text)
+ Hledger.Flow.Common: showCmdArgsAt :: [FilePath] -> [Text] -> Text
+ Hledger.Flow.Common: tildePath :: [FilePath] -> FilePath -> FilePath
+ Hledger.Flow.Common: writeGeneratedText :: FilePath -> Text -> IO ()
+ Hledger.Flow.DocHelpers: Docs :: DocLink
+ Hledger.Flow.DocHelpers: GettingStarted :: DocLink
+ Hledger.Flow.DocHelpers: InputFiles :: DocLink
+ Hledger.Flow.DocHelpers: PreprocessScript :: DocLink
+ Hledger.Flow.DocHelpers: RulesFiles :: DocLink
+ Hledger.Flow.DocHelpers: SetLedgerFile :: DocLink
+ Hledger.Flow.DocHelpers: data DocLink
+ Hledger.Flow.DocHelpers: docSlug :: DocLink -> Text
+ Hledger.Flow.DocHelpers: instance GHC.Classes.Eq Hledger.Flow.DocHelpers.DocLink
+ Hledger.Flow.DocHelpers: instance GHC.Internal.Enum.Bounded Hledger.Flow.DocHelpers.DocLink
+ Hledger.Flow.DocHelpers: instance GHC.Internal.Enum.Enum Hledger.Flow.DocHelpers.DocLink
+ Hledger.Flow.DocHelpers: instance GHC.Internal.Show.Show Hledger.Flow.DocHelpers.DocLink
+ Hledger.Flow.DocHelpers: siteURL :: Text
+ Hledger.Flow.Import.IncludeFiles: extraIncludesForFile :: (HasVerbosity o, HasBaseDir o) => o -> TChan LogMessage -> FilePath -> [Text] -> [FilePath] -> [FilePath] -> IO IncludeFileMap
+ Hledger.Flow.Import.IncludeFiles: includePreamble :: Text
+ Hledger.Flow.Import.IncludeFiles: includesNeedUpdate :: BaseDir -> AbsDir -> [AbsFile] -> [AbsFile] -> IO Bool
+ Hledger.Flow.Import.IncludeFiles: toIncludeFiles :: (HasBaseDir o, HasVerbosity o) => o -> TChan LogMessage -> IncludeFileMap -> IO (Map FilePath Text)
+ Hledger.Flow.Import.IncludeFiles: toIncludeLine :: FilePath -> FilePath -> Text
+ Hledger.Flow.Import.IncludeFiles: writeIncludesFromInventory :: (HasBaseDir o, HasVerbosity o) => o -> TChan LogMessage -> AbsDir -> [AbsFile] -> [AbsFile] -> IO [AbsFile]
+ Hledger.Flow.Import.IncludeFiles: writeToplevelAllYearsInclude :: (HasBaseDir o, HasVerbosity o) => o -> IO [FilePath]
+ Hledger.Flow.Import.IncludeFiles: yearsIncludeMap :: [FilePath] -> IncludeFileMap
+ Hledger.Flow.Import.Inventory: cannotAlias :: [String] -> Bool
+ Hledger.Flow.Import.Inventory: reconciliationInventory :: AbsDir -> Maybe [AbsFile] -> IO ([AbsFile], [AbsFile], [AbsFile])
+ Hledger.Flow.Import.Types: type IncludeFileMap = Map FilePath [FilePath]
+ Hledger.Flow.PathHelpers: NotADirectory :: FilePath -> PathException
+ Hledger.Flow.PathHelpers: isGlobMetacharacter :: Char -> Bool
+ Hledger.Flow.PathHelpers: literalGlobPath :: FilePath -> FilePath
+ Hledger.Flow.PathHelpers: resolveExistingDir :: (MonadIO m, MonadThrow m) => AbsDir -> FilePath -> m AbsDir
+ Hledger.Flow.PathHelpers: withStagedOutput :: AbsFile -> (AbsFile -> IO ()) -> IO ()
+ Hledger.Flow.ReleaseNotice: CheckUnavailable :: ReleaseResult
+ Hledger.Flow.ReleaseNotice: UpToDate :: ReleaseResult
+ Hledger.Flow.ReleaseNotice: UpdateAvailable :: !Version -> !Text -> ReleaseResult
+ Hledger.Flow.ReleaseNotice: data ReleaseResult
+ Hledger.Flow.ReleaseNotice: instance GHC.Classes.Eq Hledger.Flow.ReleaseNotice.ReleaseResult
+ Hledger.Flow.ReleaseNotice: instance GHC.Internal.Show.Show Hledger.Flow.ReleaseNotice.ReleaseResult
+ Hledger.Flow.ReleaseNotice: renderUpdate :: Bool -> Version -> Text -> Text
+ Hledger.Flow.RuntimeOptions: [hledgerExecutable] :: RuntimeOptions -> AbsFile
+ Hledger.Flow.RuntimeOptions: [hledgerVersion] :: RuntimeOptions -> IO Text
+ Hledger.Flow.RuntimeOptions: hledgerInfo :: RuntimeOptions -> IO HledgerInfo
+ Hledger.Flow.Status.Render: yellow :: Bool -> Text -> Text
+ Hledger.Flow.Types: FoundConf :: FilePath -> HledgerConf
+ Hledger.Flow.Types: GivenConf :: FilePath -> HledgerConf
+ Hledger.Flow.Types: data HledgerConf
+ Hledger.Flow.Types: hledgerConfPath :: HledgerConf -> FilePath
+ Hledger.Flow.Types: instance GHC.Classes.Eq Hledger.Flow.Types.HledgerConf
+ Hledger.Flow.Types: instance GHC.Internal.Show.Show Hledger.Flow.Types.HledgerConf
- Hledger.Flow.Common: errExit :: Int -> TChan LogMessage -> Text -> a -> IO a
+ Hledger.Flow.Common: errExit :: Int -> TChan LogMessage -> Text -> IO a
- Hledger.Flow.Common: errExit' :: Int -> (Text -> IO ()) -> Text -> a -> IO a
+ Hledger.Flow.Common: errExit' :: Int -> (Text -> IO ()) -> Text -> IO a
- Hledger.Flow.Common: groupValuesBy :: (Ord k, Ord v) => (v -> k) -> [v] -> Map k [v]
+ Hledger.Flow.Common: groupValuesBy :: Ord k => (v -> k) -> [v] -> Map k [v]
- Hledger.Flow.DocHelpers: docURL :: Text -> Text
+ Hledger.Flow.DocHelpers: docURL :: DocLink -> Text
- Hledger.Flow.Environment: environmentJSON :: SystemInfo -> Maybe FilePath -> Value
+ Hledger.Flow.Environment: environmentJSON :: SystemInfo -> Maybe HledgerConf -> Value
- Hledger.Flow.Environment: environmentSettings :: SystemInfo -> Maybe HledgerInfo -> Maybe FilePath -> BaseDir -> [Setting]
+ Hledger.Flow.Environment: environmentSettings :: [FilePath] -> SystemInfo -> Maybe HledgerInfo -> Maybe HledgerConf -> [Setting]
- Hledger.Flow.Environment: importSettings :: RuntimeOptions -> [Setting]
+ Hledger.Flow.Environment: importSettings :: [FilePath] -> HledgerInfo -> RuntimeOptions -> [Setting]
- Hledger.Flow.Environment: reportSettings :: RuntimeOptions -> [Setting]
+ Hledger.Flow.Environment: reportSettings :: [FilePath] -> HledgerInfo -> RuntimeOptions -> [Setting]
- Hledger.Flow.Import.ImportHelpers: extractImportDirs :: Path b File -> Either Text (ImportDirs b)
+ Hledger.Flow.Import.ImportHelpers: extractImportDirs :: Path b Dir -> Path b File -> Either Text (ImportDirs b)
- Hledger.Flow.ReleaseNotice: checkRelease :: (String -> IO (Maybe ByteString)) -> IO FilePath -> IO (Maybe Text)
+ Hledger.Flow.ReleaseNotice: checkRelease :: (String -> IO (Maybe ByteString)) -> IO FilePath -> IO ReleaseResult
- Hledger.Flow.ReleaseNotice: startCheck :: IO (Maybe Text) -> IO (IO ())
+ Hledger.Flow.ReleaseNotice: startCheck :: IO ReleaseResult -> IO (IO ())
- Hledger.Flow.RuntimeOptions: RuntimeOptions :: BaseDir -> RunDir -> Maybe Integer -> Bool -> Text -> HledgerInfo -> Maybe FilePath -> SystemInfo -> Bool -> Bool -> Bool -> Int -> Bool -> RuntimeOptions
+ Hledger.Flow.RuntimeOptions: RuntimeOptions :: BaseDir -> RunDir -> Maybe Integer -> Bool -> Text -> AbsFile -> IO Text -> Maybe HledgerConf -> SystemInfo -> Bool -> Bool -> Bool -> Int -> Bool -> RuntimeOptions
- Hledger.Flow.RuntimeOptions: [hledgerConf] :: RuntimeOptions -> Maybe FilePath
+ Hledger.Flow.RuntimeOptions: [hledgerConf] :: RuntimeOptions -> Maybe HledgerConf
- Hledger.Flow.Status: nextStep :: Integer -> Shell -> AbsDir -> LedgerStatus -> NextStep
+ Hledger.Flow.Status: nextStep :: Integer -> Shell -> [FilePath] -> AbsDir -> LedgerStatus -> NextStep
- Hledger.Flow.Status.Render: nextStepHere :: LedgerStatus -> IO NextStep
+ Hledger.Flow.Status.Render: nextStepHere :: [FilePath] -> LedgerStatus -> IO NextStep
- Hledger.Flow.Status.Render: renderEnvironment :: Bool -> SystemInfo -> Maybe FilePath -> LedgerStatus -> Text
+ Hledger.Flow.Status.Render: renderEnvironment :: Bool -> [FilePath] -> SystemInfo -> Maybe HledgerConf -> LedgerStatus -> Text
- Hledger.Flow.Status.Render: renderStatus :: Bool -> Maybe AbsDir -> LedgerStatus -> NextStep -> Text
+ Hledger.Flow.Status.Render: renderStatus :: Bool -> [FilePath] -> LedgerStatus -> NextStep -> Text
- Hledger.Flow.Status.Render: statusJSON :: SystemInfo -> Maybe FilePath -> LedgerStatus -> NextStep -> Value
+ Hledger.Flow.Status.Render: statusJSON :: SystemInfo -> Maybe HledgerConf -> LedgerStatus -> NextStep -> Value

Files

ChangeLog.md view
@@ -10,6 +10,9 @@ - Say what users can now do, what behaves differently or what action they   need to take. Name the affected platform, command, option, file or output   when it helps readers recognise the change.+- When a change affects multiple platforms, list Linux first, then macOS,+  then Windows. Omit unaffected platforms and keep corresponding figures+  in the same order. - Keep each entry brief. Put commands and step-by-step instructions in the   relevant guide. Link directly to that section when readers need details.   Use a full URL so the link also works in the GitHub release notes.@@ -27,6 +30,69 @@   here because Windows users can install it. Automating publication to an   existing destination belongs in release-procedure.md. -->++## 0.20.0++- If you have statements with brackets in their filenames, such as+  `April [1].csv`, run `hledger-flow import` to regenerate their journals.+  Each now imports exactly once alongside `April 1.csv`.+- If a statement is newer than its cached CSV and its preprocess hook is+  missing, import stops and names both files. Restore the hook or move the+  cached CSV aside, then run `hledger-flow import`.+- The documentation has moved to the [project site](https://hledger-flow.github.io/).+  Links in hledger-flow's messages and `--help` open the relevant section+  there, and links printed by earlier versions still lead to the right page.+  The rewritten [tutorial](https://hledger-flow.github.io/tutorial/) shows how+  far hledger's rules files go before you need a preprocess or construct script.+- `report` now writes aggregate annual and monthly income statements and+  balance sheets even with zero or one owner, so they include root-level+  manual entries. Owner reports still show only that owner's entries.+- In a benchmark rebuilding 124 statements, `import` took 11% less time on+  Linux, 42% less on macOS, and 8% less on Windows than in 0.19.0. When+  all four statements selected from that archive were up to date,+  `import --new-files-only` took 82%, 71%, and 81% less time, respectively.+  Parallel imports and reports start the next file as soon as a worker is+  free and default to one file per core. Use `--batch-size` to change this.+- Dots before a statement's rules override are now supported. For example,+  `2024.04_rfo-card.csv` uses `rfo-card.rules` when present.+- `import` no longer ends with a `Next:` suggestion. Run `hledger-flow status`+  to see the next step.+- `import --new-files-only` now picks up statement corrections made later in+  the same second as the previous output.+- If writing a rules-generated journal or an include file fails, the previous+  complete copy stays in place for the next import.+- Running `import` from a subfolder of a statement year, such as+  `1-in/2024/archive/`, now limits conversion and orphan cleanup to that+  account. Sibling accounts keep their outputs.+- `status` and `--show-options` now name the+  [hledger config file](https://hledger-flow.github.io/reference/status/#hledger-config-file)+  that hledger finds by itself, such as `~/.hledger.conf`, instead of showing+  none. Options in that file apply to import and reports, and can change the+  generated journals.+- `status`, `init`, `--show-options` and import and report messages show+  paths in your home folder as `~`. The commands they suggest write the home+  folder as `"$HOME/…"`, so you can still paste them. `status --json` keeps+  [full paths](https://hledger-flow.github.io/reference/status/#paths-in-your-home-folder).+- An empty owner folder created for future statements no longer stops+  `report` for owners who already have journals.+- `status` marks journals outdated when a shared CSV rules file changes,+  including rules included by another rules file.+- Owners, banks and accounts named `import` are supported when importing+  statements and checking their status from the ledger root.+- Ctrl-C during hledger version detection now cancels `status`, including+  `status --json`.+- Failed imports and reports finish writing their diagnostics before exiting,+  including when output is piped to a slow reader.+- `import --start-year` now rejects mistyped years such as `24` before+  importing. Use four digits or `current`.+- `status --rules-template` gives every CSV column a distinct field name,+  even when the header already contains a name such as `Reference_2`.+- If `hledger --version` fails, `import` and `report` now name the command and+  its exit code instead of exiting silently.+- When a symlink in a `3-journal` folder points to a generated journal, the+  ledger now counts its transactions once.+- Local Linux x86-64 Snap builds now package the hledger-flow version named+  on the snap and verify the executable before installation.  ## 0.19.0 
README.md view
@@ -29,8 +29,8 @@  ## Overview of the Basic Workflow -1. Save each statement under its [account and year](docs/README.md#input-files).-2. Add an [hledger rules file](docs/README.md#rules-files) for the account or bank.+1. Save each statement under its [account and year](https://hledger-flow.github.io/reference/input-files/).+2. Add an [hledger rules file](https://hledger-flow.github.io/reference/rules-files/) for the account or bank.    For most bank CSVs, rules are all you need. 3. Run `hledger-flow import` from the base directory. Use the generated    `all-years.journal` as your `LEDGER_FILE`.@@ -43,7 +43,7 @@ Purchases from City Rail currently appear under `expenses:misc`. You also recorded a one-off cash fare by hand in `import/john/_manual_/2024/post-import.journal`. Keep handwritten adjustments-in a [manual journal](docs/README.md#manually-managed-journals), since each+in a [manual journal](https://hledger-flow.github.io/reference/manual-journals/), since each full import rewrites the statement journals in `3-journal/`.  Add this rule to `import/mybank.rules`, the rules file used by those statements:@@ -68,114 +68,25 @@  ## How do I install it? -| Installation method | Platforms | Choose this if you want to… |-| --- | --- | --- |-| [Download a GitHub release](#download-a-github-release) | Windows, macOS, Linux | Run a prebuilt executable. |-| [Install with Homebrew](#install-with-homebrew) | macOS, Linux | Install and upgrade with `brew`. |-| [Build from source](#build-from-source) | Windows, macOS, Linux | Compile the project yourself with Stack. |--### Download a GitHub release--1. Download the archive for your OS and processor from the [latest GitHub release](https://github.com/apauley/hledger-flow/releases/latest).-2. Extract it and copy `hledger-flow` (or `hledger-flow.exe` on Windows) to a directory on your `PATH`.-   On macOS, [extract and run it from your terminal](QUICKSTART.md#install-on-macos), not Finder.-3. [Install hledger](https://hledger.org/install.html) for imports and reports.--The [quick-start guide](QUICKSTART.md) lists the archive names and walks through installation and your first import on each platform.--#### Verifying a download--From v0.17.0 onward, GitHub releases are immutable and the archives carry build-attestations. To verify a download, substitute its release tag and archive name-in both [GitHub CLI](https://cli.github.com/) commands:--```sh-gh release verify-asset v0.17.0 hledger-flow-macos-arm64.tar.gz -R apauley/hledger-flow-gh attestation verify hledger-flow-macos-arm64.tar.gz -R apauley/hledger-flow-```--The first command checks that the archive matches the published release. The-second checks its build provenance. The macOS binaries are signed with a-Developer ID and submitted to Apple for notarization.--### Install with Homebrew--On a [supported Mac or Linux system](https://github.com/FernTrail/homebrew-tap#supported-platforms),-check that `brew --version` works in your terminal. If you’re new to Homebrew,-[install Homebrew](https://brew.sh/) and follow the installer’s instructions to add `brew` to your `PATH`.--To set up FernTrail’s package repository (its *tap*) for the first time:--```sh-brew tap ferntrail/tap-```--Optionally, to inspect the package before installing it, trust just the-hledger-flow formula and show its details (Homebrew 6 or newer):--```sh-brew trust --formula ferntrail/tap/hledger-flow-brew info ferntrail/tap/hledger-flow-```--Install hledger-flow and its `hledger` dependency:+Install with Homebrew on macOS or Linux:  ```sh brew install ferntrail/tap/hledger-flow ``` -If you don’t want Homebrew to install `hledger`, add `--without-hledger` to the install command.-The fully qualified install command also works on its own: Homebrew adds the tap-automatically and trusts only the named formula. See [Homebrew’s tap trust guide](https://docs.brew.sh/Tap-Trust).--For later releases, run `brew update` and `brew upgrade ferntrail/tap/hledger-flow`.--### Build from source--From a checkout of this repository, run `stack install`. Add the directory-reported by `stack path --local-bin` to your `PATH` and [install hledger](https://hledger.org/install.html).-See the [build instructions](CONTRIBUTING.md#build-the-project) for prerequisites and tests.--## Getting Started--Check that both commands are available with `hledger-flow --version` and-`hledger --version`. To select a specific copy of hledger, use-`hledger-flow --hledger-path /path/to/hledger import`. Windows users can-write account hooks in PowerShell (`preprocess.ps1` and `construct.ps1`)-or use native executables. See [Windows import hooks](docs/README.md#windows-import-hooks).--Have a look at the [detailed step-by-step instructions](step-by-step/README.md) and the [feature reference](docs/README.md).--You can see the example imported financial transactions as it was-generated by the step-by-step instructions here:--<https://github.com/apauley/hledger-flow-example>--## Compatibility with hledger--`hledger-flow` should work with any recent version of `hledger`.--## Compatibility with ledger-cli--`hledger-flow` uses `hledger` to produce journal's, so this page about [hledger and Ledger](https://hledger.org/ledger.html) should be relevant for all `hledger-flow` users.--That said, here are some observations that are specifically relevant to `hledger-flow`:--When writing out the journal include files, `hledger-flow` sorts the-include statements by filename.--[Ledger](https://www.ledger-cli.org/) fails any balance assertions-when the transactions aren't included in chronological order.+Or download a release for Windows, macOS or Linux, or build from source.+The [quick start](https://hledger-flow.github.io/quickstart/) covers each option, how to verify a+download, and your first import. -An easy way around this is to name your input files so that March's-statement is listed before December's statement.+## Documentation -Another option is to add `--permissive` to any-[ledger](https://www.ledger-cli.org/) command.+The full documentation is at **<https://hledger-flow.github.io/>**: -So you should easily be able to use both `ledger` and `hledger` on these-journals if you take care to [avoid the few incompatibilities](https://hledger.org/faq.html#how-is-hledger-different-from-ledger-) which exists-(eg in your rules files or manual journals).+- [Quick start](https://hledger-flow.github.io/quickstart/)+- [Tutorial](https://hledger-flow.github.io/tutorial/)+- [Reference](https://hledger-flow.github.io/reference/status/)+- [Transfers between your accounts](https://hledger-flow.github.io/guides/transfers/)+- [Using Ledger](https://hledger-flow.github.io/guides/ledger/)  ## Project Goals @@ -203,76 +114,3 @@ # Contributing to hledger-flow  Have a look at the [contribution guidelines](CONTRIBUTING.md).--# FAQ--## How do you balance transfers between 2 accounts when you have statements for both accounts?--### The Problem--In your primary bank account you've happily been classifying transfers to a-secondary account as just `Expenses:OtherAccount`.--But you've recently started processing the statements from the second account as-well so that you can classify those expenses more accurately.--And now the balances of these two accounts are all wrong when the statements of-each account deals with money transferred between these two accounts.--In `bank1.journal`, imported from `bank1.csv`:--    2018/11/09 Transfer from primary account to secondary account-        Assets:Bank1:Primary    $-200-        Assets:Bank2:Secondary--In `bank2.journal`, imported from `bank2.csv`:--    2018/11/09 Transfer from primary account to secondary account-        Assets:Bank2:Secondary  $200-        Assets:Bank1:Primary--### The Solution--As soon as you start importing statements for both accounts you will have to-introduce an intermediate account for classification between these two accounts.--I use `Assets:Transfers:*`.--And we may have reports looking at these transfers accounts at some point, you-should consider using the same names.--The above example then becomes as follows.--In `bank1.journal`, imported from `bank1.csv`:--    2019-05-18 Transfer from primary account to secondary account-        Assets:Bank1:Primary         $-200-        Assets:Transfers:Bank1Bank2--In `bank2.journal`, imported from `bank2.csv`:--    2019-05-18 Transfer from primary account to secondary account-        Assets:Bank2:Secondary       $200-        Assets:Transfers:Bank1Bank2--Any posting to `Assets:Transfers:*` indicates an in "in-flight" amount.-You would expect the balance of `Assets:Transfers` to be zero most of the time.-Whenever it isn't zero it means that you either don't yet have the other side of-the transfer, or that something is wrong in your rules.--You could theoretically just use `Assets:Transfers` without any subaccounts, but-I found it useful to use subaccounts. Because then the subaccounts can show me-where I should look for any missing transfer transaction.--I typically use sorted names as the subaccount (Python code sample):--``` python-"Assets:Transfers:" + "".join(sorted(["Bank2", "Bank1"]))-```--### External references--This approach is based on what is described in Full-fledged hledger:-<https://github.com/adept/full-fledged-hledger/wiki/Adding-more-accounts#lets-make-sure-that-transfers-are-not-double-counted>--The question was first asked in [issue \#51](https://github.com/apauley/hledger-flow/issues/51).
app/Main.hs view
@@ -5,7 +5,7 @@  module Main where -import Parsing ( parseStartYear )+import Parsing ( StartYear, resolveStartYear, startYearReader ) import Path ( reldir )  import Prelude hiding (putStrLn)@@ -36,15 +36,18 @@       str,       switch ) -import Hledger.Flow.Common ( hledgerInfoFromPath )+import Hledger.Flow.Types (HledgerInfo (..))+import Hledger.Flow.Common ( deferredHledgerVersion, findHledgerConf, hledgerPathFromOption, hledgerInfoFromPath ) import Hledger.Flow.Status ( Situation (..) ) import Hledger.Flow.Status.Gather ( classifyDir ) import Hledger.Flow.Status.Render ( printStatus ) import qualified Data.Text as Text import Path.IO ( getCurrentDir, resolveDir' ) import Hledger.Flow.Init ( runInit )+import Hledger.Flow.DocHelpers ( DocLink (Docs), docURL, siteURL ) import Hledger.Flow.RulesTemplate ( rulesTemplateFor )-import Hledger.Flow.Internals (versionInfo, systemInfo)+import Data.Maybe (fromMaybe)+import Hledger.Flow.Internals (availableCores, versionInfo, systemInfo) import Hledger.Flow.BaseDir ( determineBaseDir, findBaseDir ) import qualified Hledger.Flow.RuntimeOptions as RT import Hledger.Flow.ReleaseNotice ( withReleaseNotice )@@ -53,7 +56,7 @@  import qualified Data.Text.IO as T import Control.Exception (SomeAsyncException (..), SomeException, catch, displayException, fromException, throwIO)-import Hledger.Flow.PathHelpers (PathException (..), turtleToAbsDir)+import Hledger.Flow.PathHelpers (PathException (..), resolveExistingDir) import System.Exit (ExitCode (..), exitWith) import System.IO (hPutStrLn, hSetEncoding, stderr, stdout, utf8) import System.IO.Error (ioeGetErrorString, isUserError)@@ -62,7 +65,7 @@ #endif  data ImportParams = ImportParams { maybeImportBaseDir :: Maybe FilePath-                                 , importStartYear :: Maybe String+                                 , importStartYear :: Maybe StartYear                                  , onlyNewFiles :: Bool                                  } deriving (Show) @@ -120,7 +123,7 @@  runCommand :: IO () runCommand = do-  let description = "An hledger workflow focusing on automated statement import and classification:\nhttps://github.com/apauley/hledger-flow#readme"+  let description = pretty ("An hledger workflow focusing on automated statement import and classification:\n" <> Text.unpack siteURL)   cmd <- customExecParser (prefs mempty)     (info (helper <*> baseCommandParser) (headerDoc (Just description) <> footerDoc (Just topExamples)))   case cmd of@@ -161,7 +164,7 @@ runStatus :: MainParams -> StatusParams -> IO () runStatus mainParams' params = do   cwd <- getCurrentDir-  dir <- maybe (return cwd) (turtleToAbsDir cwd) (statusDir params)+  dir <- maybe (return cwd) (resolveExistingDir cwd) (statusDir params)   base <- findBaseDir dir >>= maybe (throwIO (MissingBaseDir dir)) return   case statusRulesTemplate params of     Just account -> rulesTemplateFor base (Text.pack account) >>= either (ioError . userError . Text.unpack) T.putStr@@ -172,7 +175,8 @@ runBare mainParams' = do   situation <- getCurrentDir >>= classifyDir   case situation of-    InLedger base -> printStatus (hledgerPathOpt mainParams') (hledgerConfOpt mainParams') False base+    InLedger base ->+      printStatus (hledgerPathOpt mainParams') (hledgerConfOpt mainParams') False base     EmptyDir -> T.putStrLn "This folder is empty. Run `hledger-flow init` to start your ledger here."     Elsewhere ->       T.putStr $@@ -180,35 +184,28 @@           <> "\nNo hledger-flow ledger here or in any parent folder.\n"           <> "Run `hledger-flow init` in an empty folder to start one.\n" -defaultBatchSize :: Int-defaultBatchSize = 20--determineBatchSize :: MainParams -> IO Int-determineBatchSize mainParams' =-  case (batchSize mainParams') of-    Nothing   -> return defaultBatchSize-    Just size -> return size- toRuntimeOptionsImport :: MainParams -> ImportParams -> IO RT.RuntimeOptions toRuntimeOptionsImport mainParams' subParams' = do-  startYear <- parseStartYear $ importStartYear subParams'+  startYear <- traverse resolveStartYear (importStartYear subParams')   let maybeBD = maybeImportBaseDir subParams' :: Maybe FilePath   (bd, runDir) <- determineBaseDir maybeBD-  hli <- hledgerInfoFromPath $ hledgerPathOpt mainParams'-  size <- determineBatchSize mainParams'+  hlp <- hledgerPathFromOption $ hledgerPathOpt mainParams'+  version <- deferredHledgerVersion hlp+  conf <- findHledgerConf $ hledgerConfOpt mainParams'   sysInfo <- systemInfo   return RT.RuntimeOptions { RT.baseDir = bd                            , RT.importRunDir = runDir                            , RT.importStartYear = startYear                            , RT.onlyNewFiles = onlyNewFiles subParams'                            , RT.hfVersion = versionInfo sysInfo-                           , RT.hledgerInfo = hli-                           , RT.hledgerConf = hledgerConfOpt mainParams'+                           , RT.hledgerExecutable = hlp+                           , RT.hledgerVersion = version+                           , RT.hledgerConf = conf                            , RT.sysInfo = sysInfo                            , RT.verbose = verbosity mainParams' > 0                            , RT.showOptions = showOpts mainParams'                            , RT.sequential = sequential mainParams'-                           , RT.batchSize = size+                           , RT.batchSize = fromMaybe (availableCores sysInfo) (batchSize mainParams')                            , RT.prettyReports = True                            } @@ -216,21 +213,22 @@ toRuntimeOptionsReport mainParams' subParams' = do   let maybeBD = maybeReportBaseDir subParams' :: Maybe FilePath   (bd, _) <- determineBaseDir maybeBD-  hli <- hledgerInfoFromPath $ hledgerPathOpt mainParams'-  size <- determineBatchSize mainParams'+  HledgerInfo hlp version <- hledgerInfoFromPath $ hledgerPathOpt mainParams'+  conf <- findHledgerConf $ hledgerConfOpt mainParams'   sysInfo <- systemInfo   return RT.RuntimeOptions { RT.baseDir = bd                            , RT.importRunDir = [reldir|.|]                            , RT.importStartYear = Nothing                            , RT.onlyNewFiles = False                            , RT.hfVersion = versionInfo sysInfo-                           , RT.hledgerInfo = hli-                           , RT.hledgerConf = hledgerConfOpt mainParams'+                           , RT.hledgerExecutable = hlp+                           , RT.hledgerVersion = return version+                           , RT.hledgerConf = conf                            , RT.sysInfo = sysInfo                            , RT.verbose = verbosity mainParams' > 0                            , RT.showOptions = showOpts mainParams'                            , RT.sequential = sequential mainParams'-                           , RT.batchSize = size+                           , RT.batchSize = fromMaybe (availableCores sysInfo) (batchSize mainParams')                            , RT.prettyReports = not(asciiReports subParams')                            } @@ -260,7 +258,7 @@     "  hledger-flow report          Generate reports",     "  hledger-flow status --json   The same status, for scripts and agents",     "",-    "Docs: https://github.com/apauley/hledger-flow/tree/master/docs"+    "Docs: " <> Text.unpack (docURL Docs)   ]  subcommandExamples :: [(String, [String])]@@ -283,13 +281,13 @@   <*> optional (option str (long "hledger-path" <> short 'H' <> metavar "HLEDGER-PATH" <> help "The full path to an hledger executable"))   <*> optional (option str (long "hledger-conf" <> metavar "PATH" <> hidden <> help "The hledger config file to pass to hledger --conf"))   <*> switch (long "show-options" <> hidden <> help "Print the hledger-flow version, hledger path and settings import or report runs with. status always shows them.")-  <*> optional (option auto (long "batch-size" <> metavar "SIZE" <> hidden <> help ("Number of files to process in parallel. Must be positive. Default: " <> show defaultBatchSize <> ". Ignored during sequential processing.")))+  <*> optional (option auto (long "batch-size" <> metavar "SIZE" <> hidden <> help "Number of files to process in parallel. Must be positive. Default: the number of cores in use. Ignored during sequential processing."))   <*> switch (long "sequential" <> hidden <> help "Disable parallel processing")  subcommandParserImport :: Parser ImportParams subcommandParserImport = ImportParams   <$> optional (argument str (metavar "DIR" <> help "The directory to import. Use the base directory for a full import or a sub-directory for a partial import. Defaults to the current directory."))-  <*> optional (option str (long "start-year" <> metavar "YEAR" <> help "Import only from the specified year and onwards, ignoring previous years. By default all available years are imported. Valid values include a 4-digit year or 'current' for the current year"))+  <*> optional (option startYearReader (long "start-year" <> metavar "YEAR" <> help "Import only from the specified year and onwards, ignoring previous years. By default all available years are imported. Use a 4-digit year or 'current' for the current year"))   <*> switch (long "new-files-only" <> help "Skip regenerating output files that are at least as new as their source. This applies to preprocessed files and hledger journal files.")  subcommandParserReport :: Parser ReportParams
app/Parsing.hs view
@@ -1,25 +1,20 @@-{-# LANGUAGE PartialTypeSignatures #-}--module Parsing (-    parseStartYear-  ) where+module Parsing (StartYear, startYearReader, resolveStartYear) where -import Hledger.Flow.DateTime-import System.Exit (die)+import Data.Char (isDigit)+import Hledger.Flow.DateTime (currentYear)+import Options.Applicative (ReadM, eitherReader) import Text.Read (readMaybe) -parseStartYear :: Maybe String -> IO (Maybe Integer)-parseStartYear y = case y of-  Nothing -> return Nothing-  Just "current"  -> Just <$> currentYear-  Just s -> Just <$> parseInt s "Unable to parse year"+data StartYear = CurrentYear | FromYear Integer deriving (Show) -parseInt :: String -> String -> IO Integer-parseInt s errPrefix = case safeParseInt s errPrefix of-  Right i -> return i-  Left err -> die err+startYearReader :: ReadM StartYear+startYearReader = eitherReader parse+  where+    parse "current" = Right CurrentYear+    parse s+      | length s == 4, all isDigit s, Just year <- readMaybe s = Right (FromYear year)+      | otherwise = Left "expected a four-digit year or 'current'" -safeParseInt :: String -> String -> Either String Integer-safeParseInt s errPrefix = case (readMaybe s :: Maybe Integer) of-  Nothing -> Left $ errPrefix ++ " " ++ s-  Just i  -> Right i+resolveStartYear :: StartYear -> IO Integer+resolveStartYear CurrentYear = currentYear+resolveStartYear (FromYear year) = return year
hledger-flow.cabal view
@@ -5,9 +5,9 @@ -- see: https://github.com/sol/hpack  name:           hledger-flow-version:        0.19.0+version:        0.20.0 synopsis:       An hledger workflow focusing on automated statement import and classification.-description:    Please see the README on GitHub at <https://github.com/apauley/hledger-flow#readme>+description:    Documentation and a quick start are at <https://hledger-flow.github.io/> category:       Finance, Console homepage:       https://github.com/apauley/hledger-flow#readme bug-reports:    https://github.com/apauley/hledger-flow/issues@@ -20,6 +20,7 @@ extra-source-files:     README.md     ChangeLog.md+    site/go-links.json  source-repository head   type: git@@ -34,7 +35,7 @@       Hledger.Flow.Environment       Hledger.Flow.Import.CSVImport       Hledger.Flow.Import.ImportHelpers-      Hledger.Flow.Import.ImportHelpersTurtle+      Hledger.Flow.Import.IncludeFiles       Hledger.Flow.Import.Inventory       Hledger.Flow.Import.Types       Hledger.Flow.Init@@ -55,11 +56,11 @@       src   ghc-options: -Wall   build-depends:-      aeson >=2.2.5 && <2.3+      aeson >=2.2.5.1 && <2.3     , ansi-terminal >=1.1.5 && <1.2-    , async >=2.2.5 && <2.3+    , async >=2.2.6 && <2.3     , base >=4.20 && <4.22-    , bytestring ==0.12.*+    , bytestring >=0.12.2.0 && <0.13     , containers ==0.7.*     , directory >=1.3.8.5 && <1.4     , exceptions >=0.10.9 && <0.11@@ -104,7 +105,7 @@       Common.Integration       Common.Unit       CSVImport.ImportHelperTests-      CSVImport.ImportHelperTurtleTests+      CSVImport.IncludeFilesTests       CSVImport.Integration       CSVImport.Unit       Environment.Unit@@ -113,6 +114,7 @@       PathHelpers.Unit       ReleaseNotice.Unit       RulesTemplate.Unit+      RuntimeOptions.Unit       Status.Integration       Status.Unit       TestHelpers@@ -122,7 +124,7 @@   ghc-options: -threaded -rtsopts -with-rtsopts=-N   build-depends:       HUnit >=1.6.2.0 && <1.7-    , aeson >=2.2.5 && <2.3+    , aeson >=2.2.5.1 && <2.3     , base >=4.20 && <4.22     , containers ==0.7.*     , directory >=1.3.8.5 && <1.4
+ site/go-links.json view
@@ -0,0 +1,8 @@+{+  "docs": "/quickstart/",+  "getting-started": "/quickstart/#import-your-first-statement",+  "input-files": "/reference/input-files/",+  "rules-files": "/reference/rules-files/",+  "preprocess-script": "/reference/preprocess/",+  "set-ledger-file": "/quickstart/#import-your-first-statement"+}
src/Hledger/Flow/BaseDir.hs view
@@ -6,7 +6,7 @@     findBaseDir,     relativeToBase,     relativeToBase',-    turtleBaseDir,+    baseDirFilePath,     effectiveRunDir,   ) where@@ -30,7 +30,7 @@  determineBaseDir' :: AbsDir -> Maybe FilePath -> IO (BaseDir, RunDir) determineBaseDir' pwd (Just suppliedDir) = do-  absDir <- turtleToAbsDir pwd suppliedDir+  absDir <- resolveExistingDir pwd suppliedDir   determineBaseDirFromStartDir absDir determineBaseDir' pwd Nothing = determineBaseDirFromStartDir pwd @@ -54,29 +54,24 @@     then return (Just dir)     else if parent dir == dir then return Nothing else findBaseDir (parent dir) --- | We have unexpected behaviour when the runDir is deeper than the account directory,--- e.g. "1-in" or the year directory. Specifically, include files are generated incorrectly--- and some journals are written entirely outside of the baseDir.--- limitRunDir can possibly removed if the above is fixed.+-- | A directory below import/owner/bank/account selects that account. limitRunDir :: (MonadIO m, MonadThrow m) => BaseDir -> AbsDir -> m RunDir limitRunDir bd absRunDir = do   rel <- makeRelative bd absRunDir   let runDirDepth = pathSize rel-  let fun = composeN (runDirDepth - 4) parent-  let newRunDir = fun rel+  let newRunDir = limitDepth runDirDepth rel   when (runDirDepth > 4) $ do     let msg = T.pack $ "Changing runDir from " ++ show rel ++ " to " ++ show newRunDir :: T.Text     liftIO $ T.putStrLn msg   return newRunDir -composeN :: Int -> (a -> a) -> (a -> a)-composeN n f-  | n < 1 = id-  | n == 1 = f-  | otherwise = composeN (n - 1) (f . f)+  where+    limitDepth depth dir+      | depth > 4 = limitDepth (depth - 1) (parent dir)+      | otherwise = dir  relativeToBase :: (HasBaseDir o) => o -> FilePath -> FilePath-relativeToBase opts = relativeToBase' $ pathToTurtle (baseDir opts)+relativeToBase opts = relativeToBase' $ baseDirFilePath opts  relativeToBase' :: FilePath -> FilePath -> FilePath relativeToBase' bd p@@ -88,8 +83,8 @@           let suffix = concat parts            in if FilePath.takeFileName suffix == ".." then suffix ++ "/" else suffix -turtleBaseDir :: (HasBaseDir o) => o -> FilePath-turtleBaseDir opts = pathToTurtle $ baseDir opts+baseDirFilePath :: (HasBaseDir o) => o -> FilePath+baseDirFilePath opts = toFilePath $ baseDir opts  effectiveRunDir :: BaseDir -> RunDir -> AbsDir effectiveRunDir bd rd = do
src/Hledger/Flow/Common.hs view
@@ -3,28 +3,24 @@  module Hledger.Flow.Common where -import Control.Concurrent (threadDelay)-import Control.Exception (SomeException, try)-import Control.Monad (filterM, forM_, when)+import Control.Monad (filterM, forM_, unless, when) import Control.Monad.IO.Class (MonadIO, liftIO)+import Control.Concurrent.MVar (modifyMVar, newMVar) import Control.Concurrent.Async (concurrently, mapConcurrently)+import Control.Concurrent.Stream (mapConcurrentlyBounded) import Control.Concurrent.STM import Data.Char (isAlphaNum, isAscii, isDigit) import Data.Either-import Data.Function (on)-import qualified Data.List as List (groupBy, isPrefixOf, isSuffixOf, null, sortBy)-import Data.Maybe (mapMaybe)+import qualified Data.List as List (isPrefixOf, isSuffixOf, nub, null) import qualified Data.Map.Strict as Map-import Data.Ord (comparing) import qualified Data.Text as T import qualified Data.Text.IO as T import qualified Data.Text.Read as T-import Data.Time.Clock.POSIX (utcTimeToPOSIXSeconds) import qualified GHC.IO.Handle.FD as H import Hledger.Flow.DateTime (timeAction) import Hledger.Flow.BaseDir (relativeToBase) import Hledger.Flow.Logging-import Hledger.Flow.PathHelpers (AbsFile, RelDir, fromTurtleAbsFile)+import Hledger.Flow.PathHelpers (AbsFile, RelDir, literalGlobPath, withStagedOutput) import qualified Hledger.Flow.RuntimeOptions as RT import Hledger.Flow.Types import Path (reldir, relfile, toFilePath)@@ -45,13 +41,16 @@ hledgerPathFromOption pathOption = do   case pathOption of     Just h -> do-      hlAbs <- fromTurtleAbsFile h-      isOnDisk <- Path.doesFileExist hlAbs-      if isOnDisk-        then return hlAbs-        else do-          let msg = "Unable to find hledger at " <> T.pack h-          errExit' 1 (T.hPutStrLn H.stderr) msg hlAbs+      hlAbs <- Path.parseAbsFile h+      exists <- Path.doesFileExist hlAbs+      let failWith = errExit' 1 (T.hPutStrLn H.stderr)+      unless exists $ failWith ("Unable to find hledger at " <> T.pack h)+      -- Windows has no POSIX execute bit.+      unless (Sys.os == "mingw32") $ do+        permissions <- Directory.getPermissions h+        unless (Directory.executable permissions) $+          failWith ("The hledger file is not executable: " <> T.pack h)+      return hlAbs     Nothing -> do       maybeH <- Directory.findExecutable "hledger"       case maybeH of@@ -75,11 +74,22 @@   case found of     Nothing -> return Nothing     Just hlp -> do-      version <- try (hledgerVersionFromPath hlp) :: IO (Either SomeException T.Text)-      return (either (const Nothing) (Just . HledgerInfo hlp) version)+      result <- tryIOError (probeHledgerVersion hlp)+      return $ case result of+        Right (ExitSuccess, version) -> Just (HledgerInfo hlp version)+        _ -> Nothing  hledgerVersionFromPath :: AbsFile -> IO T.Text hledgerVersionFromPath hlp = do+  (status, version) <- probeHledgerVersion hlp+  case status of+    ExitSuccess -> return version+    ExitFailure code -> errExit' code (T.hPutStrLn H.stderr) $+      "Failed to run " <> showCmdArgs [T.pack (toFilePath hlp), "--version"]+        <> " (exit code " <> T.pack (show code) <> ")."++probeHledgerVersion :: AbsFile -> IO (ExitCode, T.Text)+probeHledgerVersion hlp = do   let command = (Process.proc (toFilePath hlp) ["--version"])         { Process.std_in = Process.CreatePipe, Process.std_out = Process.CreatePipe }   Process.withCreateProcess command $ \input output _ handle -> do@@ -87,25 +97,107 @@     Just stdoutPipe <- return output     version <- T.hGetContents stdoutPipe     status <- Process.waitForProcess handle-    case status of-      ExitSuccess -> return (T.strip version)-      ExitFailure _ -> exitWith status+    return (status, T.strip version) +-- Share a successful probe among parallel conversions in this invocation.+deferredHledgerVersion :: AbsFile -> IO (IO T.Text)+deferredHledgerVersion path = do+  cached <- newMVar Nothing+  return $ modifyMVar cached $ \previous -> do+    version <- maybe (hledgerVersionFromPath path) return previous+    return (Just version, version)+ hledgerInfoFromPath :: Maybe FilePath -> IO HledgerInfo hledgerInfoFromPath pathOption = do   hlp <- hledgerPathFromOption pathOption   hlv <- hledgerVersionFromPath hlp   return $ HledgerInfo hlp hlv +-- | @"hledger 1.50.2, mac-aarch64"@ → @"1.50.2"@.+hledgerVersionNumber :: T.Text -> T.Text+hledgerVersionNumber txt = case dropWhile (== "hledger") (T.words txt) of+  v : _ -> T.dropWhileEnd (== ',') v+  [] -> ""++-- | hledger started expanding globs in --file arguments in 1.52.+hledgerFileArgument :: HledgerInfo -> FilePath -> T.Text+hledgerFileArgument info path+  | Right (major, rest) <- T.decimal (hledgerVersionNumber (hlVersion info)),+    Just suffix <- T.stripPrefix "." rest,+    Right (minor, _) <- T.decimal suffix,+    (major, minor) >= ((1, 52) :: (Int, Int)) = T.pack (literalGlobPath path)+  | otherwise = T.pack path++-- | Pass only a given config; hledger finds the default one itself. hledgerConfArgs :: RT.RuntimeOptions -> [T.Text] hledgerConfArgs opts =   case RT.hledgerConf opts of-    Nothing -> []-    Just conf -> ["--conf", T.pack conf]+    Just (GivenConf conf) -> ["--conf", T.pack conf]+    _ -> [] +-- | The config file hledger will use: the given one, or the first that hledger 1.40+ finds+-- by itself, like possibleConfFiles in hledger's Hledger.Cli.Conf: hledger.conf in the current+-- directory or a parent, ~/.hledger.conf, or hledger.conf in the XDG config directory.+findHledgerConf :: Maybe FilePath -> IO (Maybe HledgerConf)+findHledgerConf (Just given) = return (Just (GivenConf given))+findHledgerConf Nothing = do+  found <- tryIOError $ do+    here <- Directory.getCurrentDirectory+    home <- Directory.getHomeDirectory+    xdg <- Directory.getXdgDirectory Directory.XdgConfig "hledger"+    let file d = d FilePath.</> if d == home then ".hledger.conf" else "hledger.conf"+    filterM Directory.doesFileExist (map file (List.nub (upToRoot here ++ [home, xdg])))+  return $ case found of+    Right (f : _) -> Just (FoundConf f)+    _ -> Nothing+  where+    upToRoot d = let up = FilePath.takeDirectory d in d : if up == d then [] else upToRoot up+ showCmdArgs :: [T.Text] -> T.Text showCmdArgs args = T.intercalate " " (map escapeArg args) +-- | The home folder as HOME spells it and as resolved, so paths from either spelling match,+-- such as macOS's /private/var for /var.+homeDirs :: IO [FilePath]+homeDirs = either (const []) id <$> tryIOError (do+  home <- Directory.getHomeDirectory+  real <- Directory.canonicalizePath home+  return (List.nub [home, real]))++-- | The path's components below the home folder; @Just []@ for the home folder itself.+belowHome :: [FilePath] -> FilePath -> Maybe [FilePath]+belowHome homes path = case [drop (length h) p | h <- map FilePath.splitDirectories homes, under h] of+  rest : _ -> Just rest+  [] -> Nothing+  where+    p = FilePath.splitDirectories path+    under h = not (null h) && length h <= length p && and (zipWith FilePath.equalFilePath h p)++-- | A path for people to read, with the home folder as @~@.+tildePath :: [FilePath] -> FilePath -> FilePath+tildePath homes path = maybe path (FilePath.joinPath . ("~" :)) (belowHome homes path)++-- | A command line for people to read, with paths under the home folder as @~@.+showCmdArgsAt :: [FilePath] -> [T.Text] -> T.Text+showCmdArgsAt homes = T.unwords . map arg+  where+    arg a = case belowHome homes (T.unpack a) of+      Just [] -> "~"+      Just rest -> "~" <> T.singleton FilePath.pathSeparator <> escapeArg (T.pack (FilePath.joinPath rest))+      Nothing -> escapeArg a++-- | A path under the home folder to paste into a command, as @"$HOME/…"@: the shell expands+-- that, unlike a quoted @~@. 'Nothing' outside the home folder, or when the rest of the path+-- has a character that is special inside double quotes; callers then paste the absolute path.+homeShellPath :: Shell -> [FilePath] -> FilePath -> Maybe T.Text+homeShellPath shell homes path = case belowHome homes path of+  Just rest | not (any (any special) rest) -> Just ("\"" <> T.pack (FilePath.joinPath ("$HOME" : rest)) <> "\"")+  _ -> Nothing+  where+    special c = case shell of+      Posix -> c `elem` ("$`\"\\!" :: String)+      PowerShell -> c `elem` ("$`\"" :: String) || (c >= '\x201c' && c <= '\x201e')+ escapeArg :: T.Text -> T.Text escapeArg a   | not (T.null a) && T.all safe a = a@@ -137,13 +229,12 @@     escape c | c == '\'' || (c >= '\x2018' && c <= '\x201b') = T.replicate 2 (T.singleton c)              | otherwise = T.singleton c -errExit :: Int -> TChan LogMessage -> T.Text -> a -> IO a+errExit :: Int -> TChan LogMessage -> T.Text -> IO a errExit exitStatus ch = errExit' exitStatus (channelErrLn ch) -errExit' :: Int -> (T.Text -> IO ()) -> T.Text -> a -> IO a-errExit' exitStatus logFun errorMessage _ = do+errExit' :: Int -> (T.Text -> IO ()) -> T.Text -> IO a+errExit' exitStatus logFun errorMessage = do   logFun errorMessage-  threadDelay 100000   exitWith (ExitFailure exitStatus)  descriptiveOutput :: T.Text -> T.Text -> T.Text@@ -166,7 +257,8 @@   IO FullTimedOutput logTimedAction opts ch cmdLabel extraCmdLabels cmd args stdoutLogger stderrLogger action = do   logVerbose opts ch $ "Begin: " <> cmdLabel-  logVerbose opts ch $ cmd <> " " <> T.intercalate " " args+  homes <- homeDirs+  logVerbose opts ch $ showCmdArgsAt homes (cmd : args)   if (List.null extraCmdLabels) then return () else logVerbose opts ch $ T.intercalate "\n" extraCmdLabels   timed@((ec, stdOut, stdErr), diff) <- timeAction action   stdoutLogger ch stdOut@@ -221,14 +313,15 @@   timed@((ec, stdOut, stdErr), _) <- logTimedAction opts ch cmdLabel extraCmdLabels cmd args stdoutLogger (\_ _ -> return ()) action   case ec of     ExitFailure i -> do-      let cmdText = cmd <> " " <> showCmdArgs args+      homes <- homeDirs+      let cmdText = showCmdArgsAt homes (cmd : args)       let msgOut = descriptiveOutput "Standard output" (trimToolOutput stdOut)       let msgErr = descriptiveOutput "Error output" (trimToolOutput stdErr)       let exitMsg =             "Failed " <> cmdLabel <> " (exit code " <> T.pack (show i) <> ").\n"               <> "Command: " <> cmdText <> "\n" <> msgOut <> msgErr               <> T.unlines hints-      errExit i ch exitMsg timed+      errExit i ch exitMsg     ExitSuccess -> do       stderrLogger ch stdErr       return timed@@ -254,14 +347,8 @@ parBatchedActions :: Int -> [a] -> [IO a] -> IO [a] parBatchedActions batch done todo   | batch < 1 = ioError (userError "--batch-size must be positive")-  | otherwise = (done ++) <$> runBatches todo-  where-    runBatches [] = return []-    runBatches actions = do-      let (current, remaining) = splitAt batch actions-      -- Keep result order, but cancel the batch as soon as any action fails.-      results <- mapConcurrently id current-      (results ++) <$> runBatches remaining+  | null (drop batch todo) = (done ++) <$> mapConcurrently id todo+  | otherwise = (done ++) <$> mapConcurrentlyBounded batch id todo  verboseTestFile :: (HasVerbosity o, HasBaseDir o) => o -> TChan LogMessage -> AbsFile -> IO Bool verboseTestFile opts ch p = do@@ -274,36 +361,14 @@  needsRegeneration :: AbsFile -> AbsFile -> IO Bool needsRegeneration src target = do-  targetExists <- Path.doesFileExist target-  if targetExists-    then do-      -- Either file can disappear after the existence check.-      newer <- tryIOError $ (>) <$> modificationSecond src <*> modificationSecond target-      return (either (const True) id newer)-    else return True-  where-    -- Preserve the whole-second comparison used by --new-files-only.-    modificationSecond :: AbsFile -> IO Integer-    modificationSecond file = floor . utcTimeToPOSIXSeconds <$> Path.getModificationTime file--groupPairs' :: (Eq a, Ord a) => [(a, b)] -> [(a, [b])]-groupPairs' =-  mapMaybe-    ( \ll -> case ll of-        [] -> Nothing-        ((k, _) : _) -> Just (k, map snd ll)-    )-    . List.groupBy ((==) `on` fst)-    . List.sortBy (comparing fst)--groupPairs :: (Eq a, Ord a) => [(a, b)] -> Map.Map a [b]-groupPairs = Map.fromList . groupPairs'--pairBy :: (a -> b) -> [a] -> [(b, a)]-pairBy keyFun = map (\v -> (keyFun v, v))+  -- A missing or unreadable timestamp also requires regeneration.+  newer <- tryIOError $ (>) <$> Path.getModificationTime src <*> Path.getModificationTime target+  return (either (const True) id newer) -groupValuesBy :: (Ord k, Ord v) => (v -> k) -> [v] -> Map.Map k [v]-groupValuesBy keyFun = groupPairs . pairBy keyFun+-- | Group by key, preserving input order within each group.+groupValuesBy :: Ord k => (v -> k) -> [v] -> Map.Map k [v]+groupValuesBy key values =+  Map.map reverse (Map.fromListWith (++) [(key v, [v]) | v <- values])  allYearsFileName :: FilePath allYearsFileName = "all-years" <.> "journal"@@ -351,7 +416,19 @@ writeTextMap :: Map.Map FilePath T.Text -> IO () writeTextMap files = forM_ (Map.toList files) $ \(path, contents) -> do   changed <- textFileNeedsUpdate path contents-  when changed $ T.writeFile path contents+  when changed $ writeGeneratedText path contents++writeGeneratedText :: FilePath -> T.Text -> IO ()+writeGeneratedText path contents = do+  -- Replace the file behind an existing symlink and keep its permissions.+  target <- Directory.canonicalizePath path >>= Path.parseAbsFile+  withStagedOutput target $ \staged -> do+    let tmp = toFilePath staged+    exists <- Path.doesFileExist target+    when exists $ do+      T.writeFile tmp ""+      Directory.copyPermissions (toFilePath target) tmp+    T.writeFile tmp contents  textFileNeedsUpdate :: FilePath -> T.Text -> IO Bool textFileNeedsUpdate path contents = either (const True) (/= contents) <$> tryIOError (T.readFile path)
src/Hledger/Flow/DocHelpers.hs view
@@ -1,8 +1,39 @@ {-# LANGUAGE OverloadedStrings #-} -module Hledger.Flow.DocHelpers where+module Hledger.Flow.DocHelpers+  ( DocLink (..),+    docSlug,+    docURL,+    siteURL,+  )+where  import Data.Text (Text) -docURL :: Text -> Text-docURL section = "https://github.com/apauley/hledger-flow/tree/master/docs#" <> section+-- | Every documentation link the CLI prints. Each one is a permanent+-- @/go/<slug>/@ URL that redirects to the current page and section, so+-- reorganising the site never breaks links printed by released binaries.+-- Redirect targets live in @site/go-links.json@; a test checks every+-- constructor has one. Never rename a slug once released.+data DocLink+  = Docs+  | GettingStarted+  | InputFiles+  | RulesFiles+  | PreprocessScript+  | SetLedgerFile+  deriving (Bounded, Enum, Eq, Show)++docSlug :: DocLink -> Text+docSlug Docs = "docs"+docSlug GettingStarted = "getting-started"+docSlug InputFiles = "input-files"+docSlug RulesFiles = "rules-files"+docSlug PreprocessScript = "preprocess-script"+docSlug SetLedgerFile = "set-ledger-file"++siteURL :: Text+siteURL = "https://hledger-flow.github.io/"++docURL :: DocLink -> Text+docURL link = siteURL <> "go/" <> docSlug link <> "/"
src/Hledger/Flow/Environment.hs view
@@ -15,10 +15,11 @@ import Data.Aeson (Value, object, (.=)) import qualified Data.Text as T import Data.Version (showVersion)+import Hledger.Flow.Common (tildePath) import Hledger.Flow.Internals (SystemInfo (..), gitCommit) import Hledger.Flow.RuntimeOptions import Hledger.Flow.Status (hledgerVersionNumber)-import Hledger.Flow.Types (BaseDir, HledgerInfo (..))+import Hledger.Flow.Types (HledgerConf (..), HledgerInfo (..), hledgerConfPath) import Path (toFilePath) import Paths_hledger_flow (version) import System.FilePath (dropTrailingPathSeparator)@@ -29,22 +30,30 @@ setting :: T.Text -> T.Text -> T.Text -> Setting setting l v f = Setting l v f False --- | What every command shares: hledger-flow, hledger, its config and the ledger.-environmentSettings :: SystemInfo -> Maybe HledgerInfo -> Maybe FilePath -> BaseDir -> [Setting]-environmentSettings sys hledger conf base =+-- | What every command shares: hledger-flow, hledger, and its config.+-- Paths under one of @homes@ show as @~@.+environmentSettings :: [FilePath] -> SystemInfo -> Maybe HledgerInfo -> Maybe HledgerConf -> [Setting]+environmentSettings homes sys hledger conf =   [ setting "hledger-flow" (T.pack (showVersion version <> " (" <> gitCommit <> "), " <> compilerName sys <> " " <> showVersion (compilerVersion sys) <> ", " <> os sys <> " " <> arch sys)) "",     case hledger of-      Just hl -> setting "hledger" (T.pack (toFilePath (hlPath hl)) <> "  (" <> hledgerVersionNumber (hlVersion hl) <> ")") "--hledger-path"+      Just hl -> setting "hledger" (shown (toFilePath (hlPath hl)) <> "  (" <> hledgerVersionNumber (hlVersion hl) <> ")") "--hledger-path"       Nothing -> Setting "hledger" "not found" "--hledger-path" True,-    setting "hledger.conf" (maybe "none" T.pack conf) "--hledger-conf",-    setting "Ledger" (T.pack (dropTrailingPathSeparator (toFilePath base))) ""+    setting "hledger.conf" confText "--hledger-conf"   ]+  where+    shown = T.pack . tildePath homes+    confText = case conf of+      Nothing -> "none"+      Just (GivenConf f) -> shown f+      Just (FoundConf f) -> shown f <> "  (found by hledger)"  coresSetting :: SystemInfo -> Setting coresSetting sys = setting "Cores" (tshow (cores sys) <> ", using " <> tshow (availableCores sys)) "" -optsEnvironment :: RuntimeOptions -> [Setting]-optsEnvironment o = environmentSettings (sysInfo o) (Just (hledgerInfo o)) (hledgerConf o) (baseDir o)+optsEnvironment :: [FilePath] -> HledgerInfo -> RuntimeOptions -> [Setting]+optsEnvironment homes info o =+  environmentSettings homes (sysInfo o) (Just info) (hledgerConf o)+    ++ [setting "Ledger" (T.pack (tildePath homes (dropTrailingPathSeparator (toFilePath (baseDir o))))) ""]  parallelism :: RuntimeOptions -> Setting parallelism o = setting "Parallelism" value "--batch-size, --sequential"@@ -54,9 +63,9 @@       | sequential o = "one file at a time"       | otherwise = tshow (batchSize o) <> " files at a time, " <> tshow (availableCores sys) <> " of " <> tshow (cores sys) <> " cores" -importSettings :: RuntimeOptions -> [Setting]-importSettings o =-  optsEnvironment o+importSettings :: [FilePath] -> HledgerInfo -> RuntimeOptions -> [Setting]+importSettings homes info o =+  optsEnvironment homes info o     ++ [ setting "Run from" runFrom "",          setting "Start year" (maybe "all years" (\y -> tshow y <> " onwards") (importStartYear o)) "--start-year",          setting "Files" (if onlyNewFiles o then "only new or changed" else "regenerate all") "--new-files-only",@@ -67,9 +76,9 @@       "." -> ". (whole ledger)"       dir -> T.pack dir -reportSettings :: RuntimeOptions -> [Setting]-reportSettings o =-  optsEnvironment o+reportSettings :: [FilePath] -> HledgerInfo -> RuntimeOptions -> [Setting]+reportSettings homes info o =+  optsEnvironment homes info o     ++ [ setting "Tables" (if prettyReports o then "pretty" else "ASCII") "--ascii-reports",          parallelism o        ]@@ -86,7 +95,7 @@           styled = if sProblem s then problem value else value        in T.stripEnd (T.justifyLeft (width sLabel) ' ' (sLabel s) <> "  " <> styled <> "   " <> sFlags s) -environmentJSON :: SystemInfo -> Maybe FilePath -> Value+environmentJSON :: SystemInfo -> Maybe HledgerConf -> Value environmentJSON sys conf =   object     [ "hledgerFlowVersion" .= showVersion version,@@ -96,7 +105,7 @@       "arch" .= arch sys,       "cores" .= cores sys,       "availableCores" .= availableCores sys,-      "hledgerConf" .= conf+      "hledgerConf" .= fmap hledgerConfPath conf     ]  tshow :: Show a => a -> T.Text
src/Hledger/Flow/Import/CSVImport.hs view
@@ -10,18 +10,17 @@ import Control.Monad import Data.Maybe (fromMaybe) import qualified Data.Text as T-import qualified Data.Text.IO as T import Hledger.Flow.DateTime (timeAction) import Hledger.Flow.Environment (importSettings, renderSettings) import Hledger.Flow.BaseDir (effectiveRunDir, relativeToBase) import Hledger.Flow.Common-import Hledger.Flow.DocHelpers (docURL)+import Hledger.Flow.DocHelpers (DocLink (..), docURL) import Hledger.Flow.Import.ImportHelpers-import Hledger.Flow.Import.ImportHelpersTurtle (writeIncludesFromInventory, writeToplevelAllYearsInclude)+import Hledger.Flow.Import.IncludeFiles (writeIncludesFromInventory, writeToplevelAllYearsInclude) import Hledger.Flow.Import.Inventory import Hledger.Flow.Import.Types import Hledger.Flow.Logging-import Hledger.Flow.PathHelpers (AbsFile)+import Hledger.Flow.PathHelpers (AbsDir, AbsFile, isGlobMetacharacter) import Hledger.Flow.RuntimeOptions import qualified Hledger.Flow.Types as FlowTypes import qualified Path@@ -34,16 +33,17 @@ import System.FilePath (dropTrailingPathSeparator, takeExtension, (</>)) import qualified Data.List as List import Hledger.Flow.Status (plural)-import Hledger.Flow.Status.Gather (accountName, gatherStatus)-import Hledger.Flow.Status.Render (nextStepHere, renderNext, useColor)-import System.IO (stdout)+import Hledger.Flow.Status.Gather (accountName) import Prelude hiding (putStrLn, take, writeFile)  type FileWasGenerated = Bool  importCSVs :: RuntimeOptions -> IO () importCSVs opts = withConsoleLogging $ \ch -> do-  when (showOptions opts) (channelOutLn ch (T.unlines (renderSettings id (importSettings opts))))+  when (showOptions opts) $ do+    homes <- homeDirs+    info <- hledgerInfo opts+    channelOutLn ch (T.unlines (renderSettings id (importSettings homes info opts)))   logVerbose opts ch "Starting import"   (journals, diff) <- timeAction $ importCSVs' opts ch   let importRoot = baseDir opts Path.</> [Path.reldir|import|]@@ -52,10 +52,6 @@       accounts = List.nub (map (accountName importRoot . fst) journals)   channelOutLn ch (importSummary scope (length journals) (length (filter snd journals)) accounts)   logVerbose opts ch ("Import took " <> T.pack (show diff))-  status <- gatherStatus (Just (hledgerInfo opts)) (baseDir opts)-  next <- nextStepHere status-  color <- useColor stdout-  channelOut ch (renderNext color next)  -- | One line saying what import did. importSummary :: T.Text -> Int -> Int -> [T.Text] -> T.Text@@ -86,14 +82,18 @@   unless (null collisions) $     errExit 1 ch ("Multiple input files map to the same output:\n" <>       T.intercalate "\n" [relative output <> " <- " <> T.intercalate ", " (map relative sources)-                         | (output, sources) <- collisions]) ()+                         | (output, sources) <- collisions])   logVerbose opts ch $ "Found " <> T.pack (show fileCount) <> " input files" <> startYearMsg <> "in " <> T.pack (show diff) <> ". Proceeding with import..."-  importedJournals <- parAwareActions opts (map (extractAndImport opts ch) inputFiles)+  -- Glob escaping depends on the hledger version. Check it before any worker+  -- writes output; conversions reuse this result. directives.journal and+  -- cached CSVs share these paths' base and statement names.+  when (any (any isGlobMetacharacter . Path.toFilePath) inputFiles) $ void (hledgerVersion opts)+  importedJournals <- parAwareActions opts (map (extractAndImport opts ch importRoot) inputFiles) -  -- Hooks may create files, so reconcile against a fresh inventory only after-  -- every selected conversion succeeds. A cached CSV is never a source.-  (refreshed, journalFindTime) <- timeAction $ findImportFiles importRoot-  (orphans, journalsOnDisk) <- classifyGeneratedOutputs importRoot refreshed (stageFiles importRoot [Path.reldir|1-in|] refreshed)+  -- Running a preprocess hook also forces conversion. When every journal was+  -- skipped, neither a hook nor a conversion could have changed the inventory.+  let cached = if any snd importedJournals then Nothing else Just inventory+  ((refreshed, orphans, journalsOnDisk), journalFindTime) <- timeAction $ reconciliationInventory importRoot cached   forM_ (filter selected orphans) $ \file -> do     target <- moveToTrash file     channelOutLn ch $ "Moved orphaned output " <> relative file <> " to " <> relative target@@ -103,12 +103,12 @@   logVerbose opts ch $ "Wrote include files for " <> T.pack (show (length journalsOnDisk)) <> " generated journals in " <> T.pack (show includeGenTime)   return importedJournals -extractAndImport :: RuntimeOptions -> TChan FlowTypes.LogMessage -> AbsFile -> IO (AbsFile, FileWasGenerated)-extractAndImport opts ch inputFile = do-  case extractImportDirs inputFile of+extractAndImport :: RuntimeOptions -> TChan FlowTypes.LogMessage -> AbsDir -> AbsFile -> IO (AbsFile, FileWasGenerated)+extractAndImport opts ch root inputFile = do+  case extractImportDirs root inputFile of     Right importDirs -> importCSV opts ch importDirs inputFile     Left errorMessage -> do-      errExit 1 ch errorMessage (inputFile, False)+      errExit 1 ch errorMessage  importCSV :: RuntimeOptions -> TChan FlowTypes.LogMessage -> ImportDirs Path.Abs -> AbsFile -> IO (AbsFile, FileWasGenerated) importCSV opts ch importDirs srcFile = do@@ -135,7 +135,15 @@   case hook of     Nothing -> do       cached <- verboseTestFile opts ch csvOut-      return (if cached then csvOut else src, False)+      if cached+        then do+          stale <- needsRegeneration src csvOut+          when stale $ ioError $ userError $+            relativeToBase opts (Path.toFilePath src) ++ " is newer than its cached CSV "+              ++ relativeToBase opts (Path.toFilePath csvOut) ++ ". Restore the preprocess hook to regenerate it,"+              ++ " or move the cached CSV aside to use the source directly. Then run hledger-flow import."+          return (csvOut, False)+        else return (src, False)     Just script -> do       regenerate <- if onlyNewFiles opts then needsRegeneration src csvOut else return True       if regenerate@@ -196,17 +204,21 @@   case maybeRulesFile of     Just rf -> do       let relRules = relativeToBase opts (Path.toFilePath rf)-      let hledger = T.pack (Path.toFilePath (FlowTypes.hlPath (hledgerInfo opts)))+      let hledger = T.pack (Path.toFilePath (hledgerExecutable opts))       let directives = directivesFile opts       directivesExist <- PathIO.doesFileExist directives-      let directivesArgs = if directivesExist then ["--file", T.pack (Path.toFilePath directives)] else []-      let csvArgs = ["--file", T.pack csvSrc, "--rules-file", T.pack (Path.toFilePath rf)]+      let paths = csvSrc : [Path.toFilePath directives | directivesExist]+      fileArgument <- if any (any isGlobMetacharacter) paths+        then hledgerFileArgument <$> hledgerInfo opts+        else return T.pack+      let directivesArgs = if directivesExist then ["--file", fileArgument (Path.toFilePath directives)] else []+      let csvArgs = ["--file", fileArgument csvSrc, "--rules-file", T.pack (Path.toFilePath rf)]       let args = hledgerConfArgs opts ++ ["print", "--explicit"] ++ directivesArgs ++ csvArgs        let cmdLabel = "importing '" <> T.pack relCSV <> "' using rules file '" <> T.pack relRules <> "'"-      let hints = ["Check the skip, fields and separator lines in " <> T.pack relRules <> ".", "Docs: " <> docURL "rules-files"]+      let hints = ["Check the skip, fields and separator lines in " <> T.pack relRules <> ".", "Docs: " <> docURL RulesFiles]       ((_, stdOut, _), _) <- timeAndExitOnErr' opts ch cmdLabel [] hints dummyLogger channelErr (hledger, args, Nothing)-      T.writeFile (Path.toFilePath journalOut) stdOut+      writeGeneratedText (Path.toFilePath journalOut) stdOut     Nothing ->       do         let candidatesTxt = T.intercalate "\n" $ map (T.pack . relativeToBase opts . Path.toFilePath) candidates@@ -215,8 +227,8 @@                 <> "\n\nI will happily use the first rules file I can find from any one of these "                 <> T.pack (show (length candidates)) <> " files:\n" <> candidatesTxt                 <> "\n\nHere is a bit of documentation about rules files that you may find helpful:\n"-                <> docURL "rules-files"-        errExit 1 ch msg ()+                <> docURL RulesFiles+        errExit 1 ch msg  hookDirArgs :: ImportDirs b -> [T.Text] hookDirArgs dirs = map (T.concat . importDirLines) [bankDir dirs, accountDir dirs, ownerDir dirs]@@ -226,11 +238,12 @@   let csvSrc = Path.toFilePath csvFile   let relScript = relativeToBase opts (Path.toFilePath constructScript)   let constructArgs = [T.pack csvSrc, "-"] ++ hookDirArgs dirs-  let constructCmdText = "Running: " <> T.pack relScript <> " " <> showCmdArgs constructArgs+  homes <- homeDirs+  let constructCmdText = "Running: " <> T.pack relScript <> " " <> showCmdArgsAt homes constructArgs   (cmd, hookArgs) <- hookCommand constructScript constructArgs   let hook = (Process.proc (T.unpack cmd) (map T.unpack hookArgs))         { Process.std_in = Process.CreatePipe, Process.std_out = Process.CreatePipe }-  let hledger = T.pack (Path.toFilePath (FlowTypes.hlPath (hledgerInfo opts)))+  let hledger = T.pack (Path.toFilePath (hledgerExecutable opts))   let args =         hledgerConfArgs opts           ++ [ "print",
src/Hledger/Flow/Import/ImportHelpers.hs view
@@ -7,34 +7,28 @@ import Data.Maybe (fromMaybe) import qualified Data.Text as T import Hledger.Flow.Common (groupValuesBy)-import Hledger.Flow.DocHelpers (docURL)+import Hledger.Flow.DocHelpers (DocLink (..), docURL) import Hledger.Flow.Import.Types (ImportDirs (..), InputFileBundle)-import Hledger.Flow.PathHelpers (AbsFile, RelDir, RelFile, pathSize)+import Hledger.Flow.PathHelpers (AbsFile, RelDir, RelFile, pathSize, withStagedOutput) import Path-import qualified Path.IO as PathIO import Control.Monad.Catch (MonadThrow)-import System.FilePath (dropExtensions, dropTrailingPathSeparator)+import System.FilePath (dropTrailingPathSeparator) import qualified System.FilePath as FilePath --- Keep hook output on the destination filesystem and publish it only on success.-withStagedOutput :: AbsFile -> (AbsFile -> IO ()) -> IO ()-withStagedOutput target writeOutput = do-  let dir = parent target-  PathIO.createDirIfMissing True dir-  PathIO.withTempDir dir ".hledger-flow-" $ \tmp -> do-    let staged = tmp </> filename target-    writeOutput staged-    PathIO.renameFile staged target--extractImportDirs :: Path b File -> Either T.Text (ImportDirs b)-extractImportDirs inputFile = walk (parent inputFile) []+extractImportDirs :: Path b Dir -> Path b File -> Either T.Text (ImportDirs b)+extractImportDirs root inputFile =+  -- Split the short relative part once: each Path.parent renormalises the whole path.+  case FilePath.splitDirectories . toFilePath <$> stripProperPrefix root inputFile of+    Just [owner, bank, account, state, year, _] -> maybe malformed Right $ do+      ownerD <- below root owner+      bankD <- below ownerD bank+      accountD <- below bankD account+      stateD <- below accountD state+      yearD <- below stateD year+      return (ImportDirs root ownerD bankD accountD stateD yearD)+    _ -> malformed   where-    walk dir children-      | dirname dir == [reldir|import|] = case children of-          [owner, bank, account, state, year] -> Right (ImportDirs dir owner bank account state year)-          _ -> malformed-      | parent dir == dir = malformed-      | otherwise = walk (parent dir) (dir : children)+    below dir name = (dir </>) <$> parseRelDir name      malformed = Left $       "I couldn't find the right number of directories between \"import\" and the input file:\n"@@ -42,7 +36,7 @@       <> "\n\nhledger-flow expects to find input files in this structure:\n"       <> "import/owner/bank/account/filestate/year/trxfile\n\n"       <> "Have a look at the documentation for a detailed explanation:\n"-      <> docURL "input-files"+      <> docURL InputFiles  groupIncludesUpTo :: RelDir -> [RelFile] -> InputFileBundle groupIncludesUpTo stopAt = collect Map.empty@@ -74,15 +68,18 @@ -- then the account's default rules files. rulesFileCandidates :: AbsFile -> ImportDirs Abs -> IO [AbsFile] rulesFileCandidates csv dirs = do-  overrides <- if "rfo" `T.isPrefixOf` suffix+  overrides <- if "rfo" `T.isPrefixOf` suffix && not (T.any (== '_') suffix)     then do       name <- rulesName [suffix]       return [dir </> name | dir <- [accountDir dirs, bankDir dirs, importDir dirs]]     else return []   (overrides ++) <$> defaultRulesCandidates dirs   where-    stem = dropExtensions (toFilePath (filename csv))-    suffix = snd (T.breakOnEnd "_" (T.pack stem))+    statementName = T.pack (toFilePath (filename csv))+    -- The statement prefix may contain dots, and the override may have several extensions.+    suffix = T.takeWhile (/= '.') $ case T.breakOnEnd "_rfo" statementName of+      ("", _) -> statementName+      (_, rest) -> "rfo" <> rest  -- | The account-level and bank-level rules files, in lookup order. defaultRulesCandidates :: ImportDirs Abs -> IO [AbsFile]
− src/Hledger/Flow/Import/ImportHelpersTurtle.hs
@@ -1,165 +0,0 @@-{-# LANGUAGE OverloadedStrings #-}-{-# LANGUAGE QuasiQuotes #-}---- | Generate journal include files and their relative include directives.-module Hledger.Flow.Import.ImportHelpersTurtle-  ( extraIncludesForFile,-    includePreamble,-    includesNeedUpdate,-    toIncludeFiles,-    toIncludeLine,-    writeIncludesFromInventory,-    writeToplevelAllYearsInclude,-    yearsIncludeMap,-  )-where--import Control.Concurrent.STM (TChan)-import Control.Monad (filterM, unless)-import qualified Data.List as List (isPrefixOf, nub, sort)-import qualified Data.Map.Strict as Map-import Data.Maybe (fromMaybe)-import qualified Data.Text as T-import qualified Data.Text.IO as T-import Hledger.Flow.BaseDir (relativeToBase')-import Hledger.Flow.Common (allYearsFileName, filterPaths, groupValuesBy, textFileNeedsUpdate, writeFiles')-import Hledger.Flow.Import.ImportHelpers (groupIncludesUpTo)-import Hledger.Flow.Import.Inventory (manualIncludeFiles, managedIncludeFiles)-import Hledger.Flow.Import.Types-import Hledger.Flow.Logging (logVerbose)-import Hledger.Flow.PathHelpers (AbsDir, AbsFile)-import Hledger.Flow.Types-import qualified Path-import qualified Path.IO as PathIO-import System.Directory (doesFileExist)-import qualified System.FilePath as FilePath-import System.FilePath ((</>))--isJournalFile :: FilePath -> Bool-isJournalFile f = FilePath.takeExtension f == ".journal"--yearsIncludeMap :: [FilePath] -> TurtleFileBundle-yearsIncludeMap = groupValuesBy allYearsPath--allYearsPath :: FilePath -> FilePath-allYearsPath journal = fst (FilePath.splitFileName journal) </> allYearsFileName--toIncludeFiles :: (HasBaseDir o, HasVerbosity o) => o -> TChan LogMessage -> TurtleFileBundle -> IO (Map.Map FilePath T.Text)-toIncludeFiles opts ch = renderIncludeFiles (baseDir opts) (logVerbose opts ch)--renderIncludeFiles :: BaseDir -> (T.Text -> IO ()) -> TurtleFileBundle -> IO (Map.Map FilePath T.Text)-renderIncludeFiles base logMessage m = do-  let extras suffixes manual prices = Map.unions <$> mapM-        (\file -> findExtraIncludes base logMessage file suffixes manual prices) (Map.keys m)-  preMap <- extras ["opening.journal"] ["pre-import.journal"] []-  postMap <- extras ["closing.journal"] ["post-import.journal"] ["prices.journal"]-  return $ (addPreamble . toIncludeFiles' preMap postMap) m--toIncludeFiles' :: TurtleFileBundle -> TurtleFileBundle -> TurtleFileBundle -> Map.Map FilePath T.Text-toIncludeFiles' preMap postMap = Map.mapWithKey $ generatedIncludeText preMap postMap--addPreamble :: Map.Map FilePath T.Text -> Map.Map FilePath T.Text-addPreamble = Map.map (\txt -> includePreamble <> "\n" <> txt)--toIncludeLine :: FilePath -> FilePath -> T.Text-toIncludeLine base file = "include " <> T.pack (map forwardSlash (relativeToBase' base file))-  where-    forwardSlash c = if FilePath.isPathSeparator c then '/' else c--generatedIncludeText :: TurtleFileBundle -> TurtleFileBundle -> FilePath -> [FilePath] -> T.Text-generatedIncludeText preMap postMap outputFile fs = do-  let preFiles = fromMaybe [] $ Map.lookup outputFile preMap-  let files = List.nub . List.sort $ fs-  let postFiles = fromMaybe [] $ Map.lookup outputFile postMap-  let lns = map (toIncludeLine $ FilePath.takeDirectory outputFile) $ preFiles ++ files ++ postFiles-  T.intercalate "\n" $ lns ++ [""]--includePreamble :: T.Text-includePreamble = "### Generated by hledger-flow - DO NOT EDIT ###\n"--writeIncludesFromInventory :: (HasBaseDir o, HasVerbosity o) => o -> TChan LogMessage -> AbsDir -> [AbsFile] -> [AbsFile] -> IO [AbsFile]-writeIncludesFromInventory opts ch stopAt inventory journals = do-  (files, roots) <- expectedIncludes (baseDir opts) (logVerbose opts ch) stopAt inventory journals-  _ <- writeFiles' files-  return roots---- | Check the same contents import would write, including obsolete generated indexes.-includesNeedUpdate :: BaseDir -> AbsDir -> [AbsFile] -> [AbsFile] -> IO Bool-includesNeedUpdate base root inventory journals = do-  (includes, _) <- expectedIncludes base (const (return ())) root inventory journals-  top <- toplevelAllYearsInclude base-  anyChanged (Map.toList (Map.union top includes))-  where-    anyChanged [] = return False-    anyChanged ((file, contents) : rest) = do-      changed <- textFileNeedsUpdate file contents-      if changed then return True else anyChanged rest--expectedIncludes :: BaseDir -> (T.Text -> IO ()) -> AbsDir -> [AbsFile] -> [AbsFile] -> IO (Map.Map FilePath T.Text, [AbsFile])-expectedIncludes base logMessage stopAt inventory journals = do-  let importRoot = base Path.</> [Path.reldir|import|]-      journalFiles = filter (isJournalFile . Path.toFilePath) journals-  boundary <- Path.stripProperPrefix base stopAt-  unless (all (Path.isProperPrefixOf stopAt) journalFiles) $-    ioError (userError "Journal file outside the requested include directory")-  inputs <- mapM (Path.stripProperPrefix base) journalFiles-  manual <- manualIncludeFiles importRoot (filter (Path.isProperPrefixOf stopAt) inventory)-  seeds <- mapM (Path.stripProperPrefix base) manual-  let groups = Map.unionsWith (++)-        [ groupIncludesUpTo boundary inputs,-          groupIncludesUpTo boundary seeds,-          Map.fromList [(seed, []) | seed <- seeds]-        ]-      absolute file = Path.toFilePath (base Path.</> file)-      includes = Map.mapKeys absolute (Map.map (map absolute) groups)-      years = yearsIncludeMap (Map.keys includes)-      allAtStop = Path.toFilePath stopAt </> allYearsFileName-      indexes = Map.insertWith (++) allAtStop [] years-      old = managedIncludeFiles importRoot (filter (Path.isProperPrefixOf stopAt) inventory)-  rendered <- renderIncludeFiles base logMessage includes-  let current = Map.union rendered ((addPreamble . toIncludeFiles' Map.empty Map.empty) indexes)-      emptyIndex = includePreamble <> "\n"-  -- Users may still open obsolete generated indexes directly. Leave hand-written files alone.-  obsolete <- filterM (needsClearing emptyIndex) [file | file <- map Path.toFilePath old, Map.notMember file current]-  let files = Map.union current (Map.fromList [(file, emptyIndex) | file <- obsolete])-  return (files, [base Path.</> f | f <- Map.keys groups, Path.parent f == boundary])-  where-    needsClearing emptyIndex file = do-      text <- T.readFile file-      return (includePreamble `T.isPrefixOf` text && text /= emptyIndex)--writeToplevelAllYearsInclude :: (HasBaseDir o, HasVerbosity o) => o -> IO [FilePath]-writeToplevelAllYearsInclude opts = toplevelAllYearsInclude (baseDir opts) >>= writeFiles'--toplevelAllYearsInclude :: BaseDir -> IO (Map.Map FilePath T.Text)-toplevelAllYearsInclude base = do-  let directives = base Path.</> [Path.relfile|directives.journal|]-      top = Path.toFilePath base </> allYearsFileName-  directivesExists <- PathIO.doesFileExist directives-  let preMap = if directivesExists then Map.singleton top [Path.toFilePath directives] else Map.empty-      allTop = Map.singleton top ["import" </> allYearsFileName]-  return $ (addPreamble . toIncludeFiles' preMap Map.empty) allTop--extraIncludesForFile :: (HasVerbosity o, HasBaseDir o) => o -> TChan LogMessage -> FilePath -> [T.Text] -> [FilePath] -> [FilePath] -> IO TurtleFileBundle-extraIncludesForFile opts ch = findExtraIncludes (baseDir opts) (logVerbose opts ch)--findExtraIncludes :: BaseDir -> (T.Text -> IO ()) -> FilePath -> [T.Text] -> [FilePath] -> [FilePath] -> IO TurtleFileBundle-findExtraIncludes base logMessage file extraSuffixes manualFiles prices = do-  let name = FilePath.takeFileName file-      stem = if "." `List.isPrefixOf` name then name else FilePath.dropExtensions name-      dirprefix = takeWhile (/= '-') stem-      fileNames = [dirprefix ++ "-" ++ T.unpack suffix | suffix <- extraSuffixes]-  let suffixFiles = map (FilePath.takeDirectory file </>) fileNames-  let suffixDirFiles = map (((FilePath.takeDirectory file </> "_manual_") </> dirprefix) </>) manualFiles-  let priceFiles = map ((((FilePath.takeDirectory file </> "..") </> "prices") </> dirprefix) </>) prices-  let extraFiles = suffixFiles ++ suffixDirFiles ++ priceFiles-  filtered <- filterPaths doesFileExist extraFiles-  let relative = relativeToBase' (Path.toFilePath base)-      logMsg =-        "Looking for possible extra include files for '" <> T.pack (relative file)-          <> "' among these " <> T.pack (show (length extraFiles)) <> " options: "-          <> T.pack (show (map (T.pack . relative) extraFiles))-          <> ". Found " <> T.pack (show (length filtered)) <> ": "-          <> T.pack (show (map (T.pack . relative) filtered))-  logMessage logMsg-  return $ Map.fromList [(file, filtered)]
+ src/Hledger/Flow/Import/IncludeFiles.hs view
@@ -0,0 +1,165 @@+{-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE QuasiQuotes #-}++-- | Generate journal include files and their relative include directives.+module Hledger.Flow.Import.IncludeFiles+  ( extraIncludesForFile,+    includePreamble,+    includesNeedUpdate,+    toIncludeFiles,+    toIncludeLine,+    writeIncludesFromInventory,+    writeToplevelAllYearsInclude,+    yearsIncludeMap,+  )+where++import Control.Concurrent.STM (TChan)+import Control.Monad (filterM, unless)+import qualified Data.List as List (isPrefixOf, nub, sort)+import qualified Data.Map.Strict as Map+import Data.Maybe (fromMaybe)+import qualified Data.Text as T+import qualified Data.Text.IO as T+import Hledger.Flow.BaseDir (relativeToBase')+import Hledger.Flow.Common (allYearsFileName, filterPaths, groupValuesBy, textFileNeedsUpdate, writeFiles')+import Hledger.Flow.Import.ImportHelpers (groupIncludesUpTo)+import Hledger.Flow.Import.Inventory (manualIncludeFiles, managedIncludeFiles)+import Hledger.Flow.Import.Types+import Hledger.Flow.Logging (logVerbose)+import Hledger.Flow.PathHelpers (AbsDir, AbsFile, literalGlobPath)+import Hledger.Flow.Types+import qualified Path+import qualified Path.IO as PathIO+import System.Directory (doesFileExist)+import qualified System.FilePath as FilePath+import System.FilePath ((</>))++isJournalFile :: FilePath -> Bool+isJournalFile f = FilePath.takeExtension f == ".journal"++yearsIncludeMap :: [FilePath] -> IncludeFileMap+yearsIncludeMap = groupValuesBy allYearsPath++allYearsPath :: FilePath -> FilePath+allYearsPath journal = fst (FilePath.splitFileName journal) </> allYearsFileName++toIncludeFiles :: (HasBaseDir o, HasVerbosity o) => o -> TChan LogMessage -> IncludeFileMap -> IO (Map.Map FilePath T.Text)+toIncludeFiles opts ch = renderIncludeFiles (baseDir opts) (logVerbose opts ch)++renderIncludeFiles :: BaseDir -> (T.Text -> IO ()) -> IncludeFileMap -> IO (Map.Map FilePath T.Text)+renderIncludeFiles base logMessage m = do+  let extras suffixes manual prices = Map.unions <$> mapM+        (\file -> findExtraIncludes base logMessage file suffixes manual prices) (Map.keys m)+  preMap <- extras ["opening.journal"] ["pre-import.journal"] []+  postMap <- extras ["closing.journal"] ["post-import.journal"] ["prices.journal"]+  return $ (addPreamble . toIncludeFiles' preMap postMap) m++toIncludeFiles' :: IncludeFileMap -> IncludeFileMap -> IncludeFileMap -> Map.Map FilePath T.Text+toIncludeFiles' preMap postMap = Map.mapWithKey $ generatedIncludeText preMap postMap++addPreamble :: Map.Map FilePath T.Text -> Map.Map FilePath T.Text+addPreamble = Map.map (\txt -> includePreamble <> "\n" <> txt)++toIncludeLine :: FilePath -> FilePath -> T.Text+toIncludeLine base file = "include " <> T.pack (literalGlobPath (map forwardSlash (relativeToBase' base file)))+  where+    forwardSlash c = if FilePath.isPathSeparator c then '/' else c++generatedIncludeText :: IncludeFileMap -> IncludeFileMap -> FilePath -> [FilePath] -> T.Text+generatedIncludeText preMap postMap outputFile fs = do+  let preFiles = fromMaybe [] $ Map.lookup outputFile preMap+  let files = List.nub . List.sort $ fs+  let postFiles = fromMaybe [] $ Map.lookup outputFile postMap+  let lns = map (toIncludeLine $ FilePath.takeDirectory outputFile) $ preFiles ++ files ++ postFiles+  T.intercalate "\n" $ lns ++ [""]++includePreamble :: T.Text+includePreamble = "### Generated by hledger-flow - DO NOT EDIT ###\n"++writeIncludesFromInventory :: (HasBaseDir o, HasVerbosity o) => o -> TChan LogMessage -> AbsDir -> [AbsFile] -> [AbsFile] -> IO [AbsFile]+writeIncludesFromInventory opts ch stopAt inventory journals = do+  (files, roots) <- expectedIncludes (baseDir opts) (logVerbose opts ch) stopAt inventory journals+  _ <- writeFiles' files+  return roots++-- | Check the same contents import would write, including obsolete generated indexes.+includesNeedUpdate :: BaseDir -> AbsDir -> [AbsFile] -> [AbsFile] -> IO Bool+includesNeedUpdate base root inventory journals = do+  (includes, _) <- expectedIncludes base (const (return ())) root inventory journals+  top <- toplevelAllYearsInclude base+  anyChanged (Map.toList (Map.union top includes))+  where+    anyChanged [] = return False+    anyChanged ((file, contents) : rest) = do+      changed <- textFileNeedsUpdate file contents+      if changed then return True else anyChanged rest++expectedIncludes :: BaseDir -> (T.Text -> IO ()) -> AbsDir -> [AbsFile] -> [AbsFile] -> IO (Map.Map FilePath T.Text, [AbsFile])+expectedIncludes base logMessage stopAt inventory journals = do+  let importRoot = base Path.</> [Path.reldir|import|]+      journalFiles = filter (isJournalFile . Path.toFilePath) journals+  boundary <- Path.stripProperPrefix base stopAt+  unless (all (Path.isProperPrefixOf stopAt) journalFiles) $+    ioError (userError "Journal file outside the requested include directory")+  inputs <- mapM (Path.stripProperPrefix base) journalFiles+  manual <- manualIncludeFiles importRoot (filter (Path.isProperPrefixOf stopAt) inventory)+  seeds <- mapM (Path.stripProperPrefix base) manual+  let groups = Map.unionsWith (++)+        [ groupIncludesUpTo boundary inputs,+          groupIncludesUpTo boundary seeds,+          Map.fromList [(seed, []) | seed <- seeds]+        ]+      absolute file = Path.toFilePath (base Path.</> file)+      includes = Map.mapKeys absolute (Map.map (map absolute) groups)+      years = yearsIncludeMap (Map.keys includes)+      allAtStop = Path.toFilePath stopAt </> allYearsFileName+      indexes = Map.insertWith (++) allAtStop [] years+      old = managedIncludeFiles importRoot (filter (Path.isProperPrefixOf stopAt) inventory)+  rendered <- renderIncludeFiles base logMessage includes+  let current = Map.union rendered ((addPreamble . toIncludeFiles' Map.empty Map.empty) indexes)+      emptyIndex = includePreamble <> "\n"+  -- Users may still open obsolete generated indexes directly. Leave hand-written files alone.+  obsolete <- filterM (needsClearing emptyIndex) [file | file <- map Path.toFilePath old, Map.notMember file current]+  let files = Map.union current (Map.fromList [(file, emptyIndex) | file <- obsolete])+  return (files, [base Path.</> f | f <- Map.keys groups, Path.parent f == boundary])+  where+    needsClearing emptyIndex file = do+      text <- T.readFile file+      return (includePreamble `T.isPrefixOf` text && text /= emptyIndex)++writeToplevelAllYearsInclude :: (HasBaseDir o, HasVerbosity o) => o -> IO [FilePath]+writeToplevelAllYearsInclude opts = toplevelAllYearsInclude (baseDir opts) >>= writeFiles'++toplevelAllYearsInclude :: BaseDir -> IO (Map.Map FilePath T.Text)+toplevelAllYearsInclude base = do+  let directives = base Path.</> [Path.relfile|directives.journal|]+      top = Path.toFilePath base </> allYearsFileName+  directivesExists <- PathIO.doesFileExist directives+  let preMap = if directivesExists then Map.singleton top [Path.toFilePath directives] else Map.empty+      allTop = Map.singleton top ["import" </> allYearsFileName]+  return $ (addPreamble . toIncludeFiles' preMap Map.empty) allTop++extraIncludesForFile :: (HasVerbosity o, HasBaseDir o) => o -> TChan LogMessage -> FilePath -> [T.Text] -> [FilePath] -> [FilePath] -> IO IncludeFileMap+extraIncludesForFile opts ch = findExtraIncludes (baseDir opts) (logVerbose opts ch)++findExtraIncludes :: BaseDir -> (T.Text -> IO ()) -> FilePath -> [T.Text] -> [FilePath] -> [FilePath] -> IO IncludeFileMap+findExtraIncludes base logMessage file extraSuffixes manualFiles prices = do+  let name = FilePath.takeFileName file+      stem = if "." `List.isPrefixOf` name then name else FilePath.dropExtensions name+      dirprefix = takeWhile (/= '-') stem+      fileNames = [dirprefix ++ "-" ++ T.unpack suffix | suffix <- extraSuffixes]+  let suffixFiles = map (FilePath.takeDirectory file </>) fileNames+  let suffixDirFiles = map (((FilePath.takeDirectory file </> "_manual_") </> dirprefix) </>) manualFiles+  let priceFiles = map ((((FilePath.takeDirectory file </> "..") </> "prices") </> dirprefix) </>) prices+  let extraFiles = suffixFiles ++ suffixDirFiles ++ priceFiles+  filtered <- filterPaths doesFileExist extraFiles+  let relative = relativeToBase' (Path.toFilePath base)+      logMsg =+        "Looking for possible extra include files for '" <> T.pack (relative file)+          <> "' among these " <> T.pack (show (length extraFiles)) <> " options: "+          <> T.pack (show (map (T.pack . relative) extraFiles))+          <> ". Found " <> T.pack (show (length filtered)) <> ": "+          <> T.pack (show (map (T.pack . relative) filtered))+  logMessage logMsg+  return $ Map.fromList [(file, filtered)]
src/Hledger/Flow/Import/Inventory.hs view
@@ -7,9 +7,10 @@     stageFiles,     inImportScope,     generatedOutput,-    expectedOutputs,     classifyGeneratedOutputs,+    reconciliationInventory,     conflictingOutputs,+    cannotAlias,     canonicalFile,     manualIncludeFiles,     isManualJournal,@@ -19,17 +20,18 @@ where  import Control.Exception (bracket_)-import Control.Monad (forM)-import Data.Char (isDigit)+import Control.Monad (filterM, forM)+import Data.Char (isAscii, isDigit, isPrint, toLower) import qualified Data.List as List import qualified Data.Map.Strict as Map import qualified Data.Set as Set+import Hledger.Flow.Common (groupValuesBy, homeDirs, tildePath) import Hledger.Flow.PathHelpers (AbsDir, AbsFile, RelDir) import Path import qualified Path.IO as PathIO import qualified System.Directory as Directory import qualified System.FilePath as FilePath-import System.IO.Error (catchIOError, isAlreadyExistsError, isDoesNotExistError)+import System.IO.Error (catchIOError, isAlreadyExistsError, isDoesNotExistError, tryIOError)  findImportFiles :: AbsDir -> IO [AbsFile] findImportFiles root = List.sort <$> PathIO.walkDirAccum (Just exclude) collect root@@ -63,31 +65,60 @@     year = FilePath.dropTrailingPathSeparator (toFilePath (dirname (parent file)))  generatedOutput :: RelDir -> String -> AbsFile -> IO AbsFile-generatedOutput stage extension source = do-  name <- parseRelFile (FilePath.replaceExtension (toFilePath (filename source)) extension)-  return (parent (parent (parent source)) </> stage </> dirname (parent source) </> name)--expectedOutputs :: [AbsFile] -> IO (Map.Map AbsFile [AbsFile])-expectedOutputs sources = do-  pairs <- outputPairs sources-  canonical <- forM pairs $ \(output, source) -> do-    key <- canonicalFile output-    return (key, [source])-  return (Map.fromListWith (++) canonical)+generatedOutput stage extension source =+  -- One parse instead of chained Path.parent calls, which each renormalise the whole path.+  parseAbsFile (account FilePath.</> toFilePath stage FilePath.</> FilePath.takeFileName yearDir FilePath.</> name)+  where+    file = toFilePath source+    yearDir = FilePath.takeDirectory file+    account = FilePath.takeDirectory (FilePath.takeDirectory yearDir)+    name = FilePath.replaceExtension (FilePath.takeFileName file) extension  -- | Orphaned generated files and the source-backed journals to include. classifyGeneratedOutputs :: AbsDir -> [AbsFile] -> [AbsFile] -> IO ([AbsFile], [AbsFile]) classifyGeneratedOutputs root inventory sources = do-  expected <- expectedOutputs sources-  let staged stage ext = filter ((== ext) . FilePath.takeExtension . toFilePath) (stageFiles root stage inventory)+  pairs <- outputPairs sources+  let expected = Set.fromList (map (toFilePath . fst) pairs)+      staged stage ext = filter ((== ext) . FilePath.takeExtension . toFilePath) (stageFiles root stage inventory)       cached = staged [reldir|2-preprocessed|] ".csv"       journals = staged [reldir|3-journal|] ".journal"-  classified <- forM (cached ++ journals) $ \file -> do-    key <- canonicalFile file-    return (file, Map.member key expected)-  return ([file | (file, False) <- classified],-          [file | (file, True) <- classified, FilePath.takeExtension (toFilePath file) == ".journal"])+      unmatched = filter ((`Set.notMember` expected) . toFilePath) (cached ++ journals)+  -- A differently spelled output can alias any expected file, even one which+  -- also matched literally (for example, through a symlink).+  orphans <- if null unmatched then return [] else do+    resolved <- Set.fromList <$> mapM (canonicalFile . fst) pairs+    filterM (fmap (`Set.notMember` resolved) . canonicalFile) unmatched+  let retired = Set.fromList orphans+      active = filter (`Set.notMember` retired) journals+      preferExpected a b = if rank a <= rank b then a else b+      rank file = (Set.notMember (toFilePath file) expected, file)+  -- Resolve year directories once, including symlinked directories. Names+  -- came from listing the directory, so only symlinks need a full lookup.+  groups <- forM (Map.toList (groupValuesBy (FilePath.takeDirectory . toFilePath) active)) $ \(dir, files) -> do+    resolvedDir <- Directory.canonicalizePath dir+    forM files $ \file -> do+      link <- PathIO.isSymlink file+      key <- if link then Directory.canonicalizePath (toFilePath file)+             else return (resolvedDir FilePath.</> FilePath.takeFileName (toFilePath file))+      return (key, file)+  let chosen = Map.fromListWith preferExpected (concat groups)+  return (orphans, List.sort (Map.elems chosen)) +-- | Inventory, orphaned outputs and source-backed journals for reconciliation.+-- Includes cover every year and account, even when orphan moves are scoped.+reconciliationInventory :: AbsDir -> Maybe [AbsFile] -> IO ([AbsFile], [AbsFile], [AbsFile])+reconciliationInventory root cached = case cached of+  Nothing -> refresh+  Just files -> do+    result@(_, orphans, _) <- classify files+    if null orphans then return result else refresh+  where+    -- A source may have been restored since discovery.+    refresh = findImportFiles root >>= classify+    classify files = do+      (orphans, journals) <- classifyGeneratedOutputs root files (stageFiles root [reldir|1-in|] files)+      return (files, orphans, journals)+ canonicalFile :: AbsFile -> IO AbsFile canonicalFile file = Directory.canonicalizePath (toFilePath file) >>= parseAbsFile @@ -104,25 +135,48 @@ conflictingOutputs :: [AbsFile] -> IO [(AbsFile, [AbsFile])] conflictingOutputs sources = do   pairs <- outputPairs sources-  grouped <- forM pairs $ \pair@(output, _) -> do-    directory <- Directory.canonicalizePath (toFilePath (parent output)) >>= parseAbsDir-    return (directory, [pair])-  fmap concat $ forM (Map.toAscList (Map.fromListWith (++) grouped)) $ \(directory, outputs) -> do-    existing <- nearestDirectory directory-    PathIO.withTempDir existing ".hledger-flow-destinations-" $ \tmp -> do-      names <- forM outputs $ \pair@(output, _) -> do-        name <- parseRelDir (toFilePath (filename output))-        let probe = tmp </> name-        PathIO.createDirIfMissing True probe-        key <- Directory.canonicalizePath (toFilePath probe)-        return (key, [pair])-      return [(output, source : map snd rest)-             | (output, source) : rest <- Map.elems (Map.fromListWith (++) names), not (null rest)]+  -- Resolve each parent once, including links shared by different accounts.+  let byParent = groupValuesBy (parent . fst) pairs+  groups <- forM (Map.toList byParent) $ \(dir, outputs) -> do+    resolved <- Directory.canonicalizePath (toFilePath dir) >>= parseAbsDir+    return (resolved, outputs)+  concat <$> mapM checkDirectory (Map.toList (Map.fromListWith (++) groups))   where+    checkDirectory (_, [_]) = return []+    checkDirectory (_, outputs) | cannotAlias (outputNames outputs) = return []+    checkDirectory (directory, outputs) = do+      let names = outputNames outputs+          distinct = Set.fromList names+      listed <- tryIOError (Directory.listDirectory (toFilePath directory))+      case listed of+        Right entries+          | Set.size distinct == length names && distinct `Set.isSubsetOf` Set.fromList entries -> return []+        _ -> do+          existing <- nearestDirectory directory+          PathIO.withTempDir existing ".hledger-flow-destinations-" $ \tmp -> do+            let probes = map (toFilePath tmp FilePath.</>) names+            mapM_ (Directory.createDirectoryIfMissing False) probes+            entries <- Directory.listDirectory (toFilePath tmp)+            if length entries == length names then return [] else do+              -- Resolve individual probes only when some names actually alias.+              keys <- mapM Directory.canonicalizePath probes+              let groups = Map.fromListWith (++) (zip keys (map (:[]) outputs))+              return [(output, source : map snd rest)+                     | (output, source) : rest <- Map.elems groups, not (null rest)]++    outputNames = map (toFilePath . filename . fst)+     nearestDirectory directory = do       exists <- PathIO.doesDirExist directory       if exists then return directory else nearestDirectory (parent directory) +-- | Generated .csv and .journal names stay distinct when they differ even+-- ignoring case, are plain ASCII and contain no Windows short-name marker (~).+cannotAlias :: [String] -> Bool+cannotAlias names = all plain names && Set.size (Set.fromList (map (map toLower) names)) == length names+  where+    plain = all (\c -> isAscii c && isPrint c && c /= '~')+ -- Seed the include at the manual file's own level; its contents are added by -- the existing pre/post renderer, keeping ordering and avoiding duplicates. manualIncludeFiles :: AbsDir -> [AbsFile] -> IO [AbsFile]@@ -159,7 +213,7 @@       destination = trash </> year </> stage       acquire = Directory.createDirectory (toFilePath lock) `catchIOError` \err ->         if isAlreadyExistsError err-          then ioError (userError ("Trash is in use: " ++ toFilePath lock+          then homeDirs >>= \homes -> ioError (userError ("Trash is in use: " ++ tildePath homes (toFilePath lock)             ++ ". If no import is running, remove this empty lock directory and retry."))           else ioError err   PathIO.createDirIfMissing True trash
src/Hledger/Flow/Import/Types.hs view
@@ -4,7 +4,7 @@ import Hledger.Flow.PathHelpers (RelFile) import Path (Dir, Path) -type TurtleFileBundle = Map.Map FilePath [FilePath]+type IncludeFileMap = Map.Map FilePath [FilePath]  type InputFileBundle = Map.Map RelFile [RelFile] 
src/Hledger/Flow/Init.hs view
@@ -9,17 +9,17 @@   ) where -import Control.Exception (IOException, catch) import Control.Monad (when) import qualified Data.Text as T import qualified Data.Text.IO as T import Hledger.Flow.BaseDir (findBaseDir)-import Hledger.Flow.Common (Shell (..), nativeShell, shellArgument)+import Data.Maybe (fromMaybe)+import Hledger.Flow.Common (Shell (..), homeDirs, homeShellPath, nativeShell, shellArgument, tildePath) import Hledger.Flow.PathHelpers (AbsDir)-import Hledger.Flow.Status.Render (printNext, tildePath)+import Hledger.Flow.Status.Render (printNext) import Hledger.Flow.Types (BaseDir) import Path-import Path.IO (createDirIfMissing, doesDirExist, getHomeDir)+import Path.IO (createDirIfMissing, doesDirExist) import qualified System.FilePath as FilePath  data InitOutcome@@ -48,9 +48,9 @@ -- | Run @hledger-flow init@ for a folder, printing what happened and what's next. runInit :: Maybe FilePath -> AbsDir -> AbsDir -> IO () runInit hledgerPath cwd dir = do-  home <- (Just <$> getHomeDir) `catch` (\e -> const (return Nothing) (e :: IOException))-  let absolute = T.pack . FilePath.dropTrailingPathSeparator . toFilePath-      shown = T.pack . tildePath home . T.unpack . absolute+  homes <- homeDirs+  let absolute = FilePath.dropTrailingPathSeparator . toFilePath+      shown = T.pack . tildePath homes . absolute   outcome <- initLedger dir   case outcome of     AlreadyLedger b ->@@ -66,5 +66,6 @@         let changeDir = case nativeShell of               Posix -> "cd "               PowerShell -> "Set-Location -LiteralPath "-        T.putStrLn ("Change to it first: " <> changeDir <> shellArgument nativeShell (absolute base))+            path = absolute base+        T.putStrLn ("Change to it first: " <> changeDir <> fromMaybe (shellArgument nativeShell (T.pack path)) (homeShellPath nativeShell homes path))       printNext hledgerPath base
src/Hledger/Flow/Logging.hs view
@@ -4,6 +4,7 @@  import Control.Concurrent.Async (wait, withAsync) import Control.Concurrent.STM+import Control.Exception (SomeAsyncException, SomeException, fromException, throwIO, tryJust) import Control.Monad (when) import qualified Data.Text as T import qualified Data.Text.IO as T@@ -41,10 +42,16 @@ withConsoleLogging action = do   ch <- newTChanIO   withAsync (consoleChannelLoop ch) $ \logger -> do-    result <- action ch+    result <- tryJust synchronous (action ch)     terminateChannelLoop ch     wait logger-    return result+    either throwIO return result+  where+    -- Drain queued diagnostics on failure, but allow Ctrl-C to cancel a blocked writer.+    synchronous :: SomeException -> Maybe SomeException+    synchronous err = case fromException err :: Maybe SomeAsyncException of+      Just _ -> Nothing+      Nothing -> Just err  consoleChannelLoop :: TChan LogMessage -> IO () consoleChannelLoop ch = do
src/Hledger/Flow/PathHelpers.hs view
@@ -5,7 +5,7 @@ import Control.Monad.Catch (Exception, MonadThrow, throwM) import Control.Monad.IO.Class (MonadIO, liftIO) import qualified Data.Text as T-import Hledger.Flow.DocHelpers (docURL)+import Hledger.Flow.DocHelpers (DocLink (..), docURL) import qualified Path import qualified Path.IO as Path import System.Directory (doesDirectoryExist)@@ -19,7 +19,29 @@  type RelDir = Path.Path Path.Rel Path.Dir -data PathException = MissingBaseDir AbsDir | InvalidTurtleDir FilePath+-- | Publish a complete file by renaming it on the destination filesystem.+withStagedOutput :: AbsFile -> (AbsFile -> IO ()) -> IO ()+withStagedOutput target writeOutput = do+  let dir = Path.parent target+  Path.createDirIfMissing True dir+  Path.withTempDir dir ".hledger-flow-" $ \tmp -> do+    let staged = tmp Path.</> Path.filename target+    writeOutput staged+    Path.renameFile staged target++isGlobMetacharacter :: Char -> Bool+isGlobMetacharacter c = c `elem` ("*?[{<" :: String)++-- | Match one literal path in an hledger glob. A range of one character+-- keeps hledger's include reader from treating the encoded spelling as a filename.+literalGlobPath :: FilePath -> FilePath+literalGlobPath = concatMap escape+  where+    escape c+      | isGlobMetacharacter c = ['[', c, '-', c, ']']+      | otherwise = [c]++data PathException = MissingBaseDir AbsDir | NotADirectory FilePath   deriving (Eq)  instance Show PathException where@@ -29,32 +51,17 @@       ++ ").\n"       ++ "Run `hledger-flow init` to start one here, or pass the ledger's folder as an argument.\n"       ++ "Docs: "-      ++ T.unpack (docURL "getting-started")-  show (InvalidTurtleDir d) = "Not a folder: " ++ d+      ++ T.unpack (docURL GettingStarted)+  show (NotADirectory d) = "Not a folder: " ++ d  instance Exception PathException -fromTurtleAbsFile :: (MonadThrow m) => FilePath -> m AbsFile-fromTurtleAbsFile turtlePath = Path.parseAbsFile turtlePath--fromTurtleRelFile :: (MonadThrow m) => FilePath -> m RelFile-fromTurtleRelFile turtlePath = Path.parseRelFile turtlePath--fromTurtleAbsDir :: (MonadThrow m) => FilePath -> m AbsDir-fromTurtleAbsDir turtlePath = Path.parseAbsDir turtlePath--fromTurtleRelDir :: (MonadThrow m) => FilePath -> m RelDir-fromTurtleRelDir turtlePath = Path.parseRelDir turtlePath--turtleToAbsDir :: (MonadIO m, MonadThrow m) => AbsDir -> FilePath -> m AbsDir-turtleToAbsDir baseDir p = do+resolveExistingDir :: (MonadIO m, MonadThrow m) => AbsDir -> FilePath -> m AbsDir+resolveExistingDir baseDir p = do   isDir <- liftIO (doesDirectoryExist p)   if isDir     then Path.resolveDir baseDir p-    else throwM $ InvalidTurtleDir p--pathToTurtle :: Path.Path b t -> FilePath-pathToTurtle = Path.toFilePath+    else throwM $ NotADirectory p  forceTrailingSlash :: FilePath -> FilePath forceTrailingSlash = FilePath.addTrailingPathSeparator . FilePath.normalise
src/Hledger/Flow/ReleaseNotice.hs view
@@ -1,7 +1,8 @@ {-# LANGUAGE OverloadedStrings #-}  module Hledger.Flow.ReleaseNotice-  ( withReleaseNotice, startCheck, finishCommand, checkRelease+  ( ReleaseResult (..), withReleaseNotice, startCheck,+    finishCommand, checkRelease, renderUpdate   ) where  import Control.Concurrent.Async (async, poll)@@ -16,6 +17,7 @@ import qualified Data.Text.Encoding as T import qualified Data.Text.IO as T import Data.Version (Version, parseVersion, showVersion)+import Hledger.Flow.Status.Render (useColor, yellow) import Network.HTTP.Client (HttpException, checkResponse, httpLbs, parseRequest, requestHeaders, responseBody, throwErrorStatusCodes) import Network.HTTP.Client.TLS (getGlobalManager) import Paths_hledger_flow (version)@@ -28,6 +30,9 @@ import System.Timeout (timeout) import Text.ParserCombinators.ReadP (eof, readP_to_S) +data ReleaseResult = UpToDate | UpdateAvailable !Version !T.Text | CheckUnavailable+  deriving (Eq, Show)+ withReleaseNotice :: IO () -> IO () withReleaseNotice command = do   finish <- setup `catchIOError` const (pure (pure ()))@@ -41,19 +46,29 @@         then startCheck (checkRelease fetch getExecutablePath)         else pure (pure ()) --- The worker belongs to this CLI invocation. Do not join it or synchronously--- cancel it on exit: either would put network cleanup on the command's path.-startCheck :: IO (Maybe T.Text) -> IO (IO ())+-- The CLI owns the worker. Let process exit handle any pending network cleanup.+startCheck :: IO ReleaseResult -> IO (IO ()) startCheck check = do-  worker <- async $ do-    notice <- check-    traverse evaluate notice+  worker <- async (check >>= evaluate)   pure $ do     result <- poll worker     case result of-      Just (Right (Just notice)) -> T.hPutStrLn stderr notice+      Just (Right (UpdateAvailable latest advice)) -> do+        color <- useColor stderr+        T.hPutStrLn stderr ("\n" <> renderUpdate color latest advice)       _ -> pure () +renderUpdate :: Bool -> Version -> T.Text -> T.Text+renderUpdate color latest advice =+  "  " <> yellow color ("↑ " <> availableMessage latest) <> "\n    " <> advice++availableMessage :: Version -> T.Text+availableMessage latest = "hledger-flow " <> renderVersion latest <> " is available (installed "+                       <> renderVersion version <> ")."++renderVersion :: Version -> T.Text+renderVersion = T.pack . showVersion+ -- The caller has already rendered ordinary errors as diagnostics + ExitCode. -- Catch just ExitCode here so that interrupts skip the notice altogether. finishCommand :: IO () -> IO () -> IO ()@@ -75,7 +90,7 @@     ignoreHttpError :: HttpException -> IO (Maybe BL.ByteString)     ignoreHttpError _ = pure Nothing -checkRelease :: (String -> IO (Maybe BL.ByteString)) -> IO FilePath -> IO (Maybe T.Text)+checkRelease :: (String -> IO (Maybe BL.ByteString)) -> IO FilePath -> IO ReleaseResult checkRelease get executable = do   result <- timeout 3000000 (get "https://api.github.com/repos/apauley/hledger-flow/releases/latest")   case join result >>= releaseVersion of@@ -84,17 +99,16 @@       let advice = if confirmed == Just True             then "Upgrade with: brew update && brew upgrade ferntrail/tap/hledger-flow"             else "https://github.com/apauley/hledger-flow/releases/latest"-      pure $ Just $ "hledger-flow " <> render latest <> " is available (installed "-                 <> render version <> ").\n" <> advice-    _ -> pure Nothing+      pure (UpdateAvailable latest advice)+    Just _ -> pure UpToDate+    Nothing -> pure CheckUnavailable   where-    render = T.pack . showVersion     homebrewOffers latest = do       path <- executable >>= canonicalizePath       owned <- ownsHomebrewBinary path       if owned then do         formula <- get "https://raw.githubusercontent.com/FernTrail/homebrew-tap/main/Formula/hledger-flow.rb"-        let tagLine = "tag = \"v" <> render latest <> "\""+        let tagLine = "tag = \"v" <> renderVersion latest <> "\""         pure $ fromMaybe False $ do           body <- formula           contents <- either (const Nothing) Just (T.decodeUtf8' (BL.toStrict body))
src/Hledger/Flow/Reports.hs view
@@ -7,7 +7,7 @@ where  import Control.Concurrent.STM-import Control.Monad (unless, when)+import Control.Monad (filterM, unless, when) import Data.Char (toLower) import Data.Either import qualified Data.List as List@@ -38,7 +38,10 @@  generateReports :: RuntimeOptions -> IO () generateReports opts = withConsoleLogging $ \ch -> do-  when (showOptions opts) $ channelOutLn ch (T.unlines (renderSettings id (reportSettings opts)))+  homes <- homeDirs+  when (showOptions opts) $ do+    info <- hledgerInfo opts+    channelOutLn ch (T.unlines (renderSettings id (reportSettings homes info opts)))   (reports, diff) <- timeAction $ generateReports' opts ch   let skipped = length (lefts reports)       skippedText = if skipped == 0 then "" else " Skipped " <> plural skipped "empty report" <> "."@@ -52,10 +55,11 @@   configured <- configuredLedgerFile   let hledgerJournal = maybe (toFilePath (baseDir opts </> [relfile|all-years.journal|])) snd configured   hledgerJournalExists <- Directory.doesFileExist hledgerJournal+  homes <- homeDirs   unless hledgerJournalExists $ ioError $ userError $ case configured of     Just (v, _) -> "LEDGER_FILE is set to " ++ v ++ ", which doesn't exist. Point it at "-      ++ toFilePath (baseDir opts </> [relfile|all-years.journal|]) ++ ", or unset it."-    Nothing -> "No all-years.journal in " ++ toFilePath (baseDir opts) ++ " yet. Run `hledger-flow import` first."+      ++ tildePath homes (toFilePath (baseDir opts </> [relfile|all-years.journal|])) ++ ", or unset it."+    Nothing -> "No all-years.journal in " ++ tildePath homes (toFilePath (baseDir opts)) ++ " yet. Run `hledger-flow import` first."   let journalWithYears = baseDir opts </> [relfile|import/all-years.journal|]   let aggregateReportDir         | any ((== toFilePath [reldir|all|]) . map toLower . toFilePath) owners = baseDir opts </> [reldir|reports/_all_|]@@ -69,7 +73,7 @@           }   let aggregateOnlyReports = reportActions opts ch [transferBalance] aggregateParams   ownerParams <- ownerParameters opts ch owners-  let ownerWithAggregateParams = (if length owners > 1 then [aggregateParams] else []) ++ ownerParams+  let ownerWithAggregateParams = aggregateParams : ownerParams   let sharedOptions = (if prettyReports opts then ["--pretty-tables"] else []) ++ ["--depth", "2"]   let ownerWithAggregateReports = List.concat $ fmap (reportActions opts ch [incomeStatement sharedOptions, incomeMonthlyStatement sharedOptions, balanceSheet sharedOptions]) ownerWithAggregateParams   let ownerOnlyReports = List.concat $ fmap (reportActions opts ch [accountList, unknownTransactions]) ownerParams@@ -118,11 +122,12 @@   let relativeJournals = map (relativeToBase opts) journals   let relativeOutputFile = relativeToBase opts (toFilePath outputFile)   let confArgs = hledgerConfArgs opts-  let fileArgs = concatMap (\file -> ["--file", T.pack file])+  info <- hledgerInfo opts+  let fileArgs = concatMap (\file -> ["--file", hledgerFileArgument info file])   let periodArgs = ["--period", T.pack (show year)]   let reportArgs = confArgs ++ fileArgs journals ++ periodArgs ++ args   let reportDisplayArgs = confArgs ++ fileArgs relativeJournals ++ periodArgs ++ args-  let hledger = T.pack (toFilePath (FlowTypes.hlPath (hledgerInfo opts)))+  let hledger = T.pack (toFilePath (FlowTypes.hlPath info))   let cmdLabel = "hledger " <> showCmdArgs reportDisplayArgs   ((_, stdOut, _), _) <- timeAndExitOnErr opts ch cmdLabel dummyLogger channelErr (hledger, reportArgs, Nothing)   if (successCheck stdOut)@@ -138,12 +143,15 @@  ownerParameters :: RuntimeOptions -> TChan FlowTypes.LogMessage -> [RelDir] -> IO [ReportParams] ownerParameters opts ch owners = do-  let actions = map (ownerParameters' opts ch) owners-  parAwareActions opts actions+  indexed <- filterM (PathIO.doesFileExist . ownerIndex opts) owners+  parAwareActions opts (map (ownerParameters' opts ch) indexed) +ownerIndex :: RuntimeOptions -> RelDir -> AbsFile+ownerIndex opts owner = baseDir opts </> [reldir|import|] </> owner </> [relfile|all-years.journal|]+ ownerParameters' :: RuntimeOptions -> TChan FlowTypes.LogMessage -> RelDir -> IO ReportParams ownerParameters' opts ch owner = do-  let ownerJournal = baseDir opts </> [reldir|import|] </> owner </> [relfile|all-years.journal|]+  let ownerJournal = ownerIndex opts owner       reportsDir = baseDir opts </> [reldir|reports|] </> owner   years <- includeYears ch ownerJournal   let directives = directivesFile opts
src/Hledger/Flow/RulesTemplate.hs view
@@ -14,9 +14,10 @@ import Data.Char (isAlphaNum, isDigit, toLower) import qualified Data.List as List import Data.Maybe (fromMaybe)+import qualified Data.Set as Set import qualified Data.Text as T import Hledger.Flow.BaseDir (relativeToBase')-import Hledger.Flow.DocHelpers (docURL)+import Hledger.Flow.DocHelpers (DocLink (..), docURL) import Hledger.Flow.Import.Inventory (findImportFiles, stageFiles) import Hledger.Flow.Status.Gather (accountName, cachedStatement) import Hledger.Flow.Types (BaseDir)@@ -66,12 +67,14 @@       | Just i == amountIx = "amount"       | isSpecialField n = "bank_" <> n       | otherwise = n-    dedupe = go []+    dedupe = snd . List.mapAccumL unique Set.empty       where-        go _ [] = []-        go seen (n : rest) =-          let k = length (filter (== n) seen)-           in (if k == 0 then n else n <> "_" <> T.pack (show (k + 1))) : go (n : seen) rest+        unique seen name = (Set.insert chosen seen, chosen)+          where+            chosen = available name (2 :: Int)+            available candidate suffix+              | Set.notMember candidate seen = candidate+              | otherwise = available (name <> "_" <> T.pack (show suffix)) (suffix + 1)  -- | Names hledger gives a meaning of its own, such as balance (assertions) or status. isSpecialField :: T.Text -> Bool@@ -83,14 +86,14 @@ notCsv path =   T.pack path <> " isn't a CSV file, so there's no header row to build rules from. "     <> "A preprocess script can convert it to CSV first: "-    <> docURL "the-preprocess-script"+    <> docURL PreprocessScript  -- | The starter rules file for a statement, from its contents. rulesTemplate :: T.Text -> T.Text -> FilePath -> T.Text -> Either T.Text T.Text rulesTemplate bank account path contents   | map toLower (FilePath.takeExtension path) `notElem` [".csv", ".tsv", ".ssv"] = Left (notCsv path)   | T.null (T.strip line) || not (looksLikeHeader names) =-      Left ("The first line of " <> T.pack path <> " looks like data, not column names. Write the fields line yourself: " <> docURL "rules-files")+      Left ("The first line of " <> T.pack path <> " looks like data, not column names. Write the fields line yourself: " <> docURL RulesFiles)   | otherwise =       Right . T.unlines $         [ "# Starter rules for " <> bank <> "/" <> account <> ", made by hledger-flow from the header row of",
src/Hledger/Flow/RuntimeOptions.hs view
@@ -1,9 +1,10 @@ module Hledger.Flow.RuntimeOptions where  import qualified Data.Text as T+import Hledger.Flow.PathHelpers (AbsFile) import Hledger.Flow.Internals (SystemInfo)-import Hledger.Flow.Types-import Prelude hiding (putStrLn)+import Hledger.Flow.Types (BaseDir, HledgerConf, HledgerInfo (..), RunDir)+import qualified Hledger.Flow.Types as Types  data RuntimeOptions = RuntimeOptions   { baseDir :: BaseDir,@@ -11,8 +12,9 @@     importStartYear :: Maybe Integer,     onlyNewFiles :: Bool,     hfVersion :: T.Text,-    hledgerInfo :: HledgerInfo,-    hledgerConf :: Maybe FilePath,+    hledgerExecutable :: AbsFile,+    hledgerVersion :: IO T.Text,+    hledgerConf :: Maybe HledgerConf,     sysInfo :: SystemInfo,     verbose :: Bool,     showOptions :: Bool,@@ -20,22 +22,24 @@     batchSize :: Int,     prettyReports :: Bool   }-  deriving (Show) -instance HasVerbosity RuntimeOptions where-  verbose (RuntimeOptions _ _ _ _ _ _ _ _ v _ _ _ _) = v+hledgerInfo :: RuntimeOptions -> IO HledgerInfo+hledgerInfo opts = HledgerInfo (hledgerExecutable opts) <$> hledgerVersion opts -instance HasSequential RuntimeOptions where-  sequential (RuntimeOptions _ _ _ _ _ _ _ _ _ _ sq _ _) = sq+instance Types.HasVerbosity RuntimeOptions where+  verbose RuntimeOptions {verbose = value} = value -instance HasBatchSize RuntimeOptions where-  batchSize (RuntimeOptions _ _ _ _ _ _ _ _ _ _ _ bs _) = bs+instance Types.HasSequential RuntimeOptions where+  sequential RuntimeOptions {sequential = value} = value -instance HasBaseDir RuntimeOptions where-  baseDir (RuntimeOptions bd _ _ _ _ _ _ _ _ _ _ _ _) = bd+instance Types.HasBatchSize RuntimeOptions where+  batchSize RuntimeOptions {batchSize = value} = value -instance HasRunDir RuntimeOptions where-  importRunDir (RuntimeOptions _ rd _ _ _ _ _ _ _ _ _ _ _) = rd+instance Types.HasBaseDir RuntimeOptions where+  baseDir RuntimeOptions {baseDir = value} = value -instance HasPrettyReports RuntimeOptions where-  prettyReports (RuntimeOptions _ _ _ _ _ _ _ _ _ _ _ _ pr) = pr+instance Types.HasRunDir RuntimeOptions where+  importRunDir RuntimeOptions {importRunDir = value} = value++instance Types.HasPrettyReports RuntimeOptions where+  prettyReports RuntimeOptions {prettyReports = value} = value
src/Hledger/Flow/Status.hs view
@@ -19,11 +19,11 @@ where  import Data.Char (isDigit, toLower)-import Data.Maybe (mapMaybe)+import Data.Maybe (fromMaybe, mapMaybe) import qualified Data.Set as Set import qualified Data.Text as T-import Hledger.Flow.Common (Shell (..), posixLiteral, powerShellLiteral, shellArgument)-import Hledger.Flow.DocHelpers (docURL)+import Hledger.Flow.Common (Shell (..), hledgerVersionNumber, homeShellPath, posixLiteral, powerShellLiteral, shellArgument, tildePath)+import Hledger.Flow.DocHelpers (DocLink (..), docURL) import Hledger.Flow.Import.Inventory (stageFiles) import Hledger.Flow.PathHelpers (AbsDir, AbsFile) import Hledger.Flow.Types (BaseDir, HledgerInfo)@@ -108,8 +108,9 @@ plural n noun = T.pack (show n) <> " " <> noun <> "s"  -- | The single most important next step. The first matching rule wins.-nextStep :: Integer -> Shell -> AbsDir -> LedgerStatus -> NextStep-nextStep year shell cwd s+-- Paths under one of @homes@ show as @~@, and as @"$HOME/…"@ in commands.+nextStep :: Integer -> Shell -> [FilePath] -> AbsDir -> LedgerStatus -> NextStep+nextStep year shell homes cwd s   | Nothing <- lsHledger s =       NextStep "Install hledger, or pass its location with --hledger-path" Nothing [] (Just "https://hledger.org/install.html")   | statements == 0 && null (lsMisplaced s) && lsManualJournals s == 0 && not (lsNeedsReconciliation s) =@@ -119,19 +120,19 @@         [ "For example: import/me/mybank/checking/1-in/" <> T.pack (show year) <> "/statement.csv",           "Then check with: " <> command "status"         ]-        (Just (docURL "input-files"))+        (Just (docURL InputFiles))   | not (null (lsMisplaced s)) =       NextStep         ("Move " <> plural (length (lsMisplaced s)) "file" <> " to where hledger-flow looks")         Nothing         [T.pack (mpPath m) <> " → " <> T.pack (mpSuggestion m) | m <- lsMisplaced s]-        (Just (docURL "input-files"))+        (Just (docURL InputFiles))   | a : _ <- filter needsRules (lsAccounts s) =       NextStep-        ("Create " <> T.pack (firstCandidate a) <> " (rules for " <> asAccount a <> ")")+        ("Create " <> T.pack (tildePath homes (firstCandidate a)) <> " (rules for " <> asAccount a <> ")")         (Just (command "status" <> " --rules-template " <> shellArgument shell (asAccount a)))         ["Save its output as that file, then edit it."]-        (Just (docURL "rules-files"))+        (Just (docURL RulesFiles))   | toImport > 0 =       NextStep ("Import " <> plural toImport "new or changed statement") (Just importCommand) [] Nothing   | not (lsAllYearsExists s) =@@ -145,14 +146,15 @@         [ "LEDGER_FILE is set to " <> T.pack (lfValue lf) <> ", which doesn't exist."           | Just lf <- [lsLedgerFile s]         ]-        (Just "https://github.com/apauley/hledger-flow/blob/master/QUICKSTART.md#import-your-first-statement")+        (Just (docURL SetLedgerFile))   | otherwise =       NextStep "Generate reports, or explore with hledger" (Just (command "report")) ["Or try: hledger balance"] Nothing   where     command subcommand = "hledger-flow " <> subcommand <> baseArgument     baseArgument       | cwd == lsBaseDir s = ""-      | otherwise = " " <> shellArgument shell (T.pack (FilePath.dropTrailingPathSeparator (toFilePath (lsBaseDir s))))+      | otherwise = " " <> pasteable (shellArgument shell) (FilePath.dropTrailingPathSeparator (toFilePath (lsBaseDir s)))+    pasteable literal path = fromMaybe (literal (T.pack path)) (homeShellPath shell homes path)     importCommand = command "import"     statements = sum (map asStatements (lsAccounts s))     toImport = sum [asJournalsMissing a + asJournalsOutdated a | a <- lsAccounts s]@@ -161,13 +163,7 @@       c : _ | cwd /= lsBaseDir s -> toFilePath (lsBaseDir s) FilePath.</> c             | otherwise -> c       [] -> "a rules file"-    allYears = T.pack (toFilePath (lsBaseDir s </> [relfile|all-years.journal|]))+    allYears = toFilePath (lsBaseDir s </> [relfile|all-years.journal|])     exportLine = case shell of-      Posix -> "export LEDGER_FILE=" <> posixLiteral allYears-      PowerShell -> "$env:LEDGER_FILE = " <> powerShellLiteral allYears---- | @"hledger 1.50.2, mac-aarch64"@ → @"1.50.2"@.-hledgerVersionNumber :: T.Text -> T.Text-hledgerVersionNumber txt = case dropWhile (== "hledger") (T.words txt) of-  v : _ -> T.dropWhileEnd (== ',') v-  [] -> ""+      Posix -> "export LEDGER_FILE=" <> pasteable posixLiteral allYears+      PowerShell -> "$env:LEDGER_FILE = " <> pasteable powerShellLiteral allYears
src/Hledger/Flow/Status/Gather.hs view
@@ -11,15 +11,19 @@   ) where -import Control.Monad (forM)+import Control.Applicative ((<|>))+import Control.Monad (forM, when) import qualified Data.List as List import qualified Data.Map.Strict as Map+import qualified Data.Set as Set import Data.Maybe (catMaybes, fromMaybe, isJust, isNothing) import qualified Data.Text as T+import qualified Data.Text.IO as T+import Data.Time.Clock (UTCTime) import Hledger.Flow.BaseDir (findBaseDir, relativeToBase')-import Hledger.Flow.Common (configuredLedgerFile, firstExistingFile)+import Hledger.Flow.Common (configuredLedgerFile, firstExistingFile, groupValuesBy) import Hledger.Flow.Import.ImportHelpers (defaultRulesCandidates, extractImportDirs, rulesFileCandidates)-import Hledger.Flow.Import.ImportHelpersTurtle (includesNeedUpdate)+import Hledger.Flow.Import.IncludeFiles (includesNeedUpdate) import Hledger.Flow.Import.Inventory (classifyGeneratedOutputs, findImportFiles, generatedOutput, isManualJournal, managedIncludeFiles, stageFiles) import Hledger.Flow.Import.Types (ImportDirs (..)) import Hledger.Flow.PathHelpers (AbsDir, AbsFile, RelFile)@@ -48,7 +52,7 @@   let root = base </> [reldir|import|]   files <- findImportFiles root   let statements = stageFiles root [reldir|1-in|] files-      groups = Map.toAscList (Map.fromListWith (flip (++)) [(accountName root s, [s]) | s <- statements])+      groups = Map.toAscList (groupValuesBy (accountName root) statements)   accounts <- forM groups (uncurry (accountStatus base))   configured <- configuredLedgerFile   ledgerFile <- forM configured $ \(value, path) -> LedgerFile value <$> Directory.doesFileExist path@@ -78,7 +82,7 @@ accountStatus :: BaseDir -> T.Text -> [AbsFile] -> IO AccountStatus accountStatus base name statements = do   dirs <- case statements of-    s : _ -> either (ioError . userError . T.unpack) return (extractImportDirs s)+    s : _ -> either (ioError . userError . T.unpack) return (extractImportDirs (base </> [reldir|import|]) s)     [] -> ioError (userError ("No statements for " ++ T.unpack name))   let relative = relativeToBase' (toFilePath base) . toFilePath   defaults <- defaultRulesCandidates dirs@@ -86,17 +90,20 @@   preprocess <- hookFile (accountDir dirs) [relfile|preprocess|]   construct <- hookFile (accountDir dirs) [relfile|construct|]   directives <- firstExistingFile [base </> [relfile|directives.journal|]]-  results <- forM statements $ \s -> do+  sources <- forM statements $ \s -> do     cached <- cachedStatement s     rules <- rulesFileCandidates (fromMaybe s cached) dirs >>= firstExistingFile-    let conversionInputs = case construct of-          Just script -> [Just script]-          Nothing -> [rules, directives]-        inputs = s : catMaybes (cached : preprocess : conversionInputs)+    return (s, cached, rules)+  let resolved = [rules | (_, _, rules) <- sources]+  -- Statements in an account commonly share one rules file.+  ruleTimes <- case construct of+    Just _ -> return Map.empty+    Nothing -> traverse (tryIOError . latestRuleChange) $+      Map.fromSet id (Set.fromList (catMaybes resolved))+  journals <- forM sources $ \(s, cached, rules) -> do+    let inputs = s : catMaybes [cached, preprocess, construct <|> directives]     journal <- generatedOutput [reldir|3-journal|] ".journal" s-    state <- journalState inputs journal-    return (rules, state)-  let (resolved, journals) = unzip results+    journalState inputs (rules >>= (`Map.lookup` ruleTimes)) journal   return     AccountStatus       { asAccount = name,@@ -115,15 +122,31 @@  data JournalState = Current | Outdated | Missing deriving (Eq) -journalState :: [AbsFile] -> AbsFile -> IO JournalState-journalState inputs journal = do+-- hledger interprets each include path relative to the file containing it.+latestRuleChange :: AbsFile -> IO UTCTime+latestRuleChange = visit Set.empty . toFilePath+  where+    visit ancestors file = do+      key <- Directory.canonicalizePath file+      when (Set.member key ancestors) $ ioError (userError ("cyclic rules include: " ++ file))+      modified <- Directory.getModificationTime file+      contents <- T.readFile file+      let children = [FilePath.takeDirectory file FilePath.</> T.unpack (T.strip path)+                     | line <- T.lines (T.dropWhile (== '\xFEFF') contents)+                     , Just path <- [T.stripPrefix "include " line]]+      times <- mapM (visit (Set.insert key ancestors)) children+      return (maximum (modified : times))++journalState :: [AbsFile] -> Maybe (Either IOError UTCTime) -> AbsFile -> IO JournalState+journalState inputs rulesTime journal = do   exists <- PathIO.doesFileExist journal   if exists     then do       -- Keep filesystem precision so a rule edited just after import is noticed.       newer <- tryIOError $ do         written <- PathIO.getModificationTime journal-        any (> written) <$> mapM PathIO.getModificationTime inputs+        times <- mapM PathIO.getModificationTime inputs+        return (any (> written) times || maybe False (either (const True) (> written)) rulesTime)       return (if either (const True) id newer then Outdated else Current)     else return Missing 
src/Hledger/Flow/Status/Render.hs view
@@ -3,34 +3,31 @@ -- | Status as text for people and JSON for scripts and agents. module Hledger.Flow.Status.Render   ( useColor,+    yellow,     renderStatus,     renderNext,     renderEnvironment,     statusJSON,-    tildePath,     nextStepHere,     printStatus,     printNext,   ) where -import Control.Exception (IOException, catch) import Data.Aeson (Value, object, (.=)) import Data.Aeson.Text (encodeToLazyText) import qualified Data.Text.IO as T import qualified Data.Text.Lazy.IO as TL-import Hledger.Flow.Common (findHledger, nativeShell)+import Hledger.Flow.Common (findHledger, findHledgerConf, homeDirs, nativeShell, tildePath) import Hledger.Flow.DateTime (currentYear) import Hledger.Flow.Environment (coresSetting, environmentJSON, environmentSettings, renderSettings) import Hledger.Flow.Internals (SystemInfo, systemInfo) import Hledger.Flow.Status.Gather (gatherStatus) import Hledger.Flow.Types (BaseDir)-import Path.IO (getCurrentDir, getHomeDir)-import qualified Data.List as List+import Path.IO (getCurrentDir) import qualified Data.Text as T-import Hledger.Flow.PathHelpers (AbsDir) import Hledger.Flow.Status-import Hledger.Flow.Types (HledgerInfo (..))+import Hledger.Flow.Types (HledgerConf, HledgerInfo (..)) import Path (toFilePath) import System.Console.ANSI (Color (..), ColorIntensity (..), ConsoleIntensity (..), ConsoleLayer (..), SGR (..), hSupportsANSIColor, setSGRCode) import System.Environment (lookupEnv)@@ -44,12 +41,6 @@   supported <- hSupportsANSIColor h   return (supported && maybe True null noColor) --- | An absolute path, with the home directory shown as @~@.-tildePath :: Maybe AbsDir -> FilePath -> FilePath-tildePath home path = case home of-  Just h | Just rest <- List.stripPrefix (toFilePath h) path -> "~" FilePath.</> rest-  _ -> path- paint :: Bool -> [SGR] -> T.Text -> T.Text paint False _ txt = txt paint True sgr txt = T.pack (setSGRCode sgr) <> txt <> T.pack (setSGRCode [Reset])@@ -63,11 +54,11 @@ baseText :: LedgerStatus -> FilePath baseText = FilePath.dropTrailingPathSeparator . toFilePath . lsBaseDir -renderStatus :: Bool -> Maybe AbsDir -> LedgerStatus -> NextStep -> T.Text-renderStatus color home s next =+renderStatus :: Bool -> [FilePath] -> LedgerStatus -> NextStep -> T.Text+renderStatus color homes s next =   T.unlines (header : accountBlock ++ misplacedBlock) <> renderNext color next   where-    header = bold color "Ledger: " <> T.pack (tildePath home (baseText s)) <> "   (" <> hledgerText <> ")"+    header = bold color "Ledger: " <> T.pack (tildePath homes (baseText s)) <> "   (" <> hledgerText <> ")"     hledgerText = case lsHledger s of       Just hl -> "hledger " <> hledgerVersionNumber (hlVersion hl) <> " found"       Nothing -> red color "hledger not found"@@ -109,7 +100,7 @@ tshow :: Show a => a -> T.Text tshow = T.pack . show -statusJSON :: SystemInfo -> Maybe FilePath -> LedgerStatus -> NextStep -> Value+statusJSON :: SystemInfo -> Maybe HledgerConf -> LedgerStatus -> NextStep -> Value statusJSON sys conf s next =   object     [ "schemaVersion" .= (1 :: Int),@@ -144,38 +135,43 @@           "journalsOutdated" .= asJournalsOutdated a         ] -nextStepHere :: LedgerStatus -> IO NextStep-nextStepHere status = do+-- | The next step for people to read with @homes@, or for scripts with @[]@ (absolute paths).+nextStepHere :: [FilePath] -> LedgerStatus -> IO NextStep+nextStepHere homes status = do   year <- currentYear   cwd <- getCurrentDir-  return (nextStep year nativeShell cwd status)+  return (nextStep year nativeShell homes cwd status) --- | The versions and paths status ran with, with full paths for bug reports.-renderEnvironment :: Bool -> SystemInfo -> Maybe FilePath -> LedgerStatus -> T.Text-renderEnvironment color sys conf s =+-- | The versions and paths status ran with, for bug reports. Paths under the home folder show as @~@.+renderEnvironment :: Bool -> [FilePath] -> SystemInfo -> Maybe HledgerConf -> LedgerStatus -> T.Text+renderEnvironment color homes sys conf s =   T.unlines ("" : bold color "Environment:" : map ("  " <>) (renderSettings (red color) settings))   where-    settings = environmentSettings sys (lsHledger s) conf (lsBaseDir s) ++ [coresSetting sys]+    settings = environmentSettings homes sys (lsHledger s) conf ++ [coresSetting sys]  -- | Print the ledger's status as text, or as JSON for scripts and agents. printStatus :: Maybe FilePath -> Maybe FilePath -> Bool -> BaseDir -> IO ()-printStatus hledgerPath conf json base = do+printStatus hledgerPath confOpt json base = do   hledger <- findHledger hledgerPath+  conf <- findHledgerConf confOpt   status <- gatherStatus hledger base-  next <- nextStepHere status   sys <- systemInfo   if json-    then TL.putStrLn (encodeToLazyText (statusJSON sys conf status next))+    then do+      next <- nextStepHere [] status+      TL.putStrLn (encodeToLazyText (statusJSON sys conf status next))     else do       color <- useColor stdout-      home <- (Just <$> getHomeDir) `catch` (\e -> const (return Nothing) (e :: IOException))-      T.putStr (renderStatus color home status next <> renderEnvironment color sys conf status)+      homes <- homeDirs+      next <- nextStepHere homes status+      T.putStr (renderStatus color homes status next <> renderEnvironment color homes sys conf status)  -- | Print only the next step for a ledger. printNext :: Maybe FilePath -> BaseDir -> IO () printNext hledgerPath base = do   hledger <- findHledger hledgerPath   status <- gatherStatus hledger base-  next <- nextStepHere status+  homes <- homeDirs+  next <- nextStepHere homes status   color <- useColor stdout   T.putStr (renderNext color next)
src/Hledger/Flow/Types.hs view
@@ -20,6 +20,13 @@  type ProcInput = (T.Text, [T.Text], Maybe Handle) +-- | The hledger config file hledger runs with: given with --hledger-conf, or found by hledger's own search.+data HledgerConf = GivenConf FilePath | FoundConf FilePath deriving (Eq, Show)++hledgerConfPath :: HledgerConf -> FilePath+hledgerConfPath (GivenConf f) = f+hledgerConfPath (FoundConf f) = f+ data HledgerInfo = HledgerInfo   { hlPath :: AbsFile,     hlVersion :: T.Text
test/BaseDir/Integration.hs view
@@ -23,7 +23,7 @@ assertDetermineBaseDir :: AbsDir -> BaseDir -> Path.Path b Dir -> IO () assertDetermineBaseDir initialPwd expectedBaseDir subDir = do   setCurrentDir initialPwd-  (bd1, runDir1) <- determineBaseDir $ Just $ pathToTurtle subDir+  (bd1, runDir1) <- determineBaseDir $ Just $ toFilePath subDir   assertFindTestFileUsingRundir bd1 runDir1    setCurrentDir subDir@@ -57,7 +57,7 @@     (bd1, runDir1) <- determineBaseDir Nothing     (bd2, runDir2) <- determineBaseDir $ Just "."     (bd3, runDir3) <- determineBaseDir $ Just "./"-    (bd4, runDir4) <- determineBaseDir $ Just $ pathToTurtle absBaseDir+    (bd4, runDir4) <- determineBaseDir $ Just $ toFilePath absBaseDir      let msg label dir = "When pwd is the base dir, determineBaseDir returns the same " ++ label ++ ", regardless of the input variation. " ++ show dir     sequence_ $ map (\dir -> assertEqual (msg "baseDir" dir) absBaseDir dir) [bd1, bd2, bd3, bd4]@@ -66,12 +66,12 @@ testBaseDirWithTempDir :: AbsDir -> AbsDir -> IO () testBaseDirWithTempDir initialPwd absoluteTempDir = do   error1 <- try $ determineBaseDir $ Just "/path/to/dir"-  assertEqual "determineBaseDir produces an error message when given a non-existant dir" (Left $ InvalidTurtleDir "/path/to/dir") error1+  assertEqual "determineBaseDir produces an error message when given a non-existant dir" (Left $ NotADirectory "/path/to/dir") error1    let unrelatedDir = absoluteTempDir </> [reldir|unrelated|]   createDir unrelatedDir -  bdUnrelated <- try $ determineBaseDir $ Just (pathToTurtle unrelatedDir)+  bdUnrelated <- try $ determineBaseDir $ Just (toFilePath unrelatedDir)   assertEqual "determineBaseDir produces an error message when it cannot find a baseDir" (Left $ MissingBaseDir unrelatedDir) bdUnrelated    let baseDir = [reldir|bd1|]@@ -86,8 +86,8 @@   createDirIfMissing True $ absoluteTempDir </> yearDir    let fictionalDir = absoluteTempDir </> ownerDir </> [reldir|fictionalDir|]-  errorSub <- try $ determineBaseDir $ Just $ pathToTurtle fictionalDir-  assertEqual "determineBaseDir produces an error message when given a non-existant subdir of a valid basedir" (Left $ InvalidTurtleDir $ pathToTurtle fictionalDir) errorSub+  errorSub <- try $ determineBaseDir $ Just $ toFilePath fictionalDir+  assertEqual "determineBaseDir produces an error message when given a non-existant subdir of a valid basedir" (Left $ NotADirectory $ toFilePath fictionalDir) errorSub    assertCurrentDirVariations absoluteTempDir baseDir @@ -100,7 +100,7 @@   let file = toFilePath (absoluteTempDir </> yearDir </> [relfile|test-file.txt|])   T.writeFile file (T.pack $ "The expected base dir is " ++ show absoluteBaseDir)   fileResult <- try $ determineBaseDir (Just file)-  assertEqual "An existing file cannot be used as the base directory" (Left (InvalidTurtleDir file)) fileResult+  assertEqual "An existing file cannot be used as the base directory" (Left (NotADirectory file)) fileResult    assertSubDirsForDetermineBaseDir absoluteTempDir absoluteBaseDir subDirs   assertSubDirsForDetermineBaseDir absoluteTempDir absoluteBaseDir absoluteSubDirs@@ -114,7 +114,7 @@  assertRunDir :: (RelDir -> RelDir) -> String -> RelDir -> IO () assertRunDir expectedRunDir msg subDir = do-  (_, runDir) <- determineBaseDir $ Just $ pathToTurtle subDir+  (_, runDir) <- determineBaseDir $ Just $ toFilePath subDir   assertEqual msg (expectedRunDir subDir) runDir  testRunDirsWithTempDir :: AbsDir -> IO ()@@ -128,9 +128,11 @@   let inDir = accDir </> [reldir|1-in|]   let yearDir = inDir </> [reldir|2019|] -  createDirIfMissing True $ baseDir </> yearDir+  let archive = yearDir </> [reldir|archive|]+  let nested = archive </> [reldir|old/downloads|]+  createDirIfMissing True $ baseDir </> nested -  withCurrentDir baseDir $ assertRunDirs accDir [accDir, bankDir, ownerDir, importDir] [yearDir, inDir]+  withCurrentDir baseDir $ assertRunDirs accDir [accDir, bankDir, ownerDir, importDir] [inDir, yearDir, archive, nested]  testRunDirs :: Test testRunDirs =
test/CSVImport/ImportHelperTests.hs view
@@ -5,7 +5,7 @@ module CSVImport.ImportHelperTests where  import qualified Data.Text as T-import Hledger.Flow.DocHelpers (docURL)+import Hledger.Flow.DocHelpers (DocLink (..), docURL) import Hledger.Flow.Import.ImportHelpers (extractImportDirs, groupIncludesUpTo) import Hledger.Flow.Import.Types (ImportDirs (..), InputFileBundle) import Path@@ -28,13 +28,13 @@           year = state </> [reldir|2019|]           statement = year </> [relfile|2019-01-01.csv|]       assertEqual "statement directories"-        (Right (ImportDirs root owner bank account state year)) (extractImportDirs statement)-      checkMalformed (account </> [relfile|2019-01-01.csv|])-      checkMalformed (year </> [relfile|extra/2019-01-01.csv|])-      checkMalformed (base </> [relfile|outside/john/mybank/checking/1-in/2019/statement.csv|])+        (Right (ImportDirs root owner bank account state year)) (extractImportDirs root statement)+      checkMalformed root (account </> [relfile|2019-01-01.csv|])+      checkMalformed root (year </> [relfile|extra/2019-01-01.csv|])+      checkMalformed root (base </> [relfile|outside/john/mybank/checking/1-in/2019/statement.csv|]) -    checkMalformed file =-      case extractImportDirs file of+    checkMalformed root file =+      case extractImportDirs root file of         Right _ -> assertFailure "expected malformed import layout to be rejected"         Left err -> assertEqual "malformed layout diagnostic"           ("I couldn't find the right number of directories between \"import\" and the input file:\n"@@ -42,7 +42,7 @@             <> "\n\nhledger-flow expects to find input files in this structure:\n"             <> "import/owner/bank/account/filestate/year/trxfile\n\n"             <> "Have a look at the documentation for a detailed explanation:\n"-            <> docURL "input-files") err+            <> docURL InputFiles) err  testGroupIncludesUpTo :: Test testGroupIncludesUpTo = TestCase $ do
− test/CSVImport/ImportHelperTurtleTests.hs
@@ -1,82 +0,0 @@-{-# LANGUAGE OverloadedStrings #-}--module CSVImport.ImportHelperTurtleTests where--import Control.Concurrent.STM (newTChanIO)-import Data.Either (lefts)-import qualified Data.Map.Strict as Map-import qualified Data.Text as T-import Hledger.Flow.Common (includeYears')-import Hledger.Flow.Import.ImportHelpersTurtle (includePreamble, toIncludeFiles, toIncludeLine, yearsIncludeMap)-import qualified Path-import System.FilePath ((</>))-import Test.HUnit-import TestHelpers (defaultOpts, withTestDir)--testIncludeYears :: Test-testIncludeYears =-  TestCase-    ( do-        let txterr = "Some text without years"-        let expectederr = ["Unable to extract years from the following text:", txterr, "Errors:"]-        let actualerr = case lefts [includeYears' txterr] of-              (errTxt : _) -> (init . T.lines) errTxt-              [] -> []-        assertEqual "Get a list of years from an include file - error case" expectederr actualerr--        let txt1 =-              "### Generated by hledger-flow - DO NOT EDIT ###\n\n"-                <> "include import/2014-include.journal\n"-                <> "include import/2015-include.journal\n"-                <> "include import/2016-include.journal\n"-                <> "include import/2017-include.journal\n"-                <> "include import/2018-include.journal\n"-                <> "include import/2019-include.journal"--        let expected1 = Right [2014 .. 2019]-        let actual1 = includeYears' txt1-        assertEqual "Get a list of years from an include file - success case 1" expected1 actual1--        let txt2 = "include 2019-include.journal"--        let expected2 = Right [2019]-        let actual2 = includeYears' txt2-        assertEqual "Get a list of years from an include file - success case 2" expected2 actual2-    )--testToIncludeLine :: Test-testToIncludeLine =-  TestCase-    ( do-        let expected = "include file1.journal"-        let relativeWithTrailingSlash = toIncludeLine "./base/dir/" "./base/dir/file1.journal"-        assertEqual "Include line - relative base dir with trailing slash" expected relativeWithTrailingSlash--        let relativeNoTrailingSlash = toIncludeLine "./base/dir" "./base/dir/file1.journal"-        assertEqual "Include line - relative base dir without a trailing slash" expected relativeNoTrailingSlash--        let absoluteWithTrailingSlash = toIncludeLine "/base/dir/" "/base/dir/file1.journal"-        assertEqual "Include line - absolute base dir with trailing slash" expected absoluteWithTrailingSlash--        let absoluteNoTrailingSlash = toIncludeLine "/base/dir" "/base/dir/file1.journal"-        assertEqual "Include line - absolute base dir without a trailing slash" expected absoluteNoTrailingSlash-    )--testIncludeRendering :: Test-testIncludeRendering = TestCase $ withTestDir $ \base -> do-  let account = Path.toFilePath base </> "import/john/bank/checking"-      year2019 = account </> "2019-include.journal"-      year2020 = account </> "2020-include.journal"-      first = account </> "3-journal/2019/first.journal"-      lastJournal = account </> "3-journal/2019/last.journal"-  assertEqual "years share their account index"-    (Map.singleton (account </> "all-years.journal") [year2019, year2020])-    (yearsIncludeMap [year2019, year2020])-  ch <- newTChanIO-  rendered <- toIncludeFiles (defaultOpts base) ch-    (Map.singleton year2019 [lastJournal, first, lastJournal])-  assertEqual "sort and deduplicate relative include lines"-    (Map.singleton year2019 (includePreamble <> "\ninclude 3-journal/2019/first.journal\ninclude 3-journal/2019/last.journal\n")) rendered--tests :: Test-tests = TestList [testIncludeYears, testToIncludeLine, testIncludeRendering]
+ test/CSVImport/IncludeFilesTests.hs view
@@ -0,0 +1,82 @@+{-# LANGUAGE OverloadedStrings #-}++module CSVImport.IncludeFilesTests where++import Control.Concurrent.STM (newTChanIO)+import Data.Either (lefts)+import qualified Data.Map.Strict as Map+import qualified Data.Text as T+import Hledger.Flow.Common (includeYears')+import Hledger.Flow.Import.IncludeFiles (includePreamble, toIncludeFiles, toIncludeLine, yearsIncludeMap)+import qualified Path+import System.FilePath ((</>))+import Test.HUnit+import TestHelpers (defaultOpts, withTestDir)++testIncludeYears :: Test+testIncludeYears =+  TestCase+    ( do+        let txterr = "Some text without years"+        let expectederr = ["Unable to extract years from the following text:", txterr, "Errors:"]+        let actualerr = case lefts [includeYears' txterr] of+              (errTxt : _) -> (init . T.lines) errTxt+              [] -> []+        assertEqual "Get a list of years from an include file - error case" expectederr actualerr++        let txt1 =+              "### Generated by hledger-flow - DO NOT EDIT ###\n\n"+                <> "include import/2014-include.journal\n"+                <> "include import/2015-include.journal\n"+                <> "include import/2016-include.journal\n"+                <> "include import/2017-include.journal\n"+                <> "include import/2018-include.journal\n"+                <> "include import/2019-include.journal"++        let expected1 = Right [2014 .. 2019]+        let actual1 = includeYears' txt1+        assertEqual "Get a list of years from an include file - success case 1" expected1 actual1++        let txt2 = "include 2019-include.journal"++        let expected2 = Right [2019]+        let actual2 = includeYears' txt2+        assertEqual "Get a list of years from an include file - success case 2" expected2 actual2+    )++testToIncludeLine :: Test+testToIncludeLine =+  TestCase+    ( do+        let expected = "include file1.journal"+        let relativeWithTrailingSlash = toIncludeLine "./base/dir/" "./base/dir/file1.journal"+        assertEqual "Include line - relative base dir with trailing slash" expected relativeWithTrailingSlash++        let relativeNoTrailingSlash = toIncludeLine "./base/dir" "./base/dir/file1.journal"+        assertEqual "Include line - relative base dir without a trailing slash" expected relativeNoTrailingSlash++        let absoluteWithTrailingSlash = toIncludeLine "/base/dir/" "/base/dir/file1.journal"+        assertEqual "Include line - absolute base dir with trailing slash" expected absoluteWithTrailingSlash++        let absoluteNoTrailingSlash = toIncludeLine "/base/dir" "/base/dir/file1.journal"+        assertEqual "Include line - absolute base dir without a trailing slash" expected absoluteNoTrailingSlash+    )++testIncludeRendering :: Test+testIncludeRendering = TestCase $ withTestDir $ \base -> do+  let account = Path.toFilePath base </> "import/john/bank/checking"+      year2019 = account </> "2019-include.journal"+      year2020 = account </> "2020-include.journal"+      first = account </> "3-journal/2019/first.journal"+      lastJournal = account </> "3-journal/2019/last.journal"+  assertEqual "years share their account index"+    (Map.singleton (account </> "all-years.journal") [year2019, year2020])+    (yearsIncludeMap [year2019, year2020])+  ch <- newTChanIO+  rendered <- toIncludeFiles (defaultOpts base) ch+    (Map.singleton year2019 [lastJournal, first, lastJournal])+  assertEqual "sort and deduplicate relative include lines"+    (Map.singleton year2019 (includePreamble <> "\ninclude 3-journal/2019/first.journal\ninclude 3-journal/2019/last.journal\n")) rendered++tests :: Test+tests = TestList [testIncludeYears, testToIncludeLine, testIncludeRendering]
test/CSVImport/Integration.hs view
@@ -6,7 +6,7 @@ import Control.Concurrent.STM import qualified Data.Map.Strict as Map import qualified Data.Text.IO as T-import Hledger.Flow.Import.ImportHelpersTurtle (extraIncludesForFile, includePreamble, toIncludeFiles, writeIncludesFromInventory, writeToplevelAllYearsInclude)+import Hledger.Flow.Import.IncludeFiles (extraIncludesForFile, includePreamble, toIncludeFiles, writeIncludesFromInventory, writeToplevelAllYearsInclude) import Hledger.Flow.Import.Inventory (findImportFiles) import Hledger.Flow.RuntimeOptions (RuntimeOptions) import Hledger.Flow.Types (LogMessage)
test/CSVImport/Unit.hs view
@@ -1,8 +1,8 @@ module CSVImport.Unit where  import qualified CSVImport.ImportHelperTests-import qualified CSVImport.ImportHelperTurtleTests+import qualified CSVImport.IncludeFilesTests import Test.HUnit  tests :: Test-tests = TestList [CSVImport.ImportHelperTests.tests, CSVImport.ImportHelperTurtleTests.tests]+tests = TestList [CSVImport.ImportHelperTests.tests, CSVImport.IncludeFilesTests.tests]
test/Common/Integration.hs view
@@ -5,17 +5,17 @@ module Common.Integration (tests) where  import Control.Concurrent.MVar (newEmptyMVar, putMVar, takeMVar, tryTakeMVar)-import Control.Concurrent.STM (atomically, newTChanIO, tryReadTChan)-import Control.Exception (bracket, finally)+import Control.Concurrent.STM (atomically, modifyTVar', newTChanIO, newTVarIO, readTVarIO, tryReadTChan)+import Control.Exception (bracket, bracket_, finally) import Control.Monad (forM_, unless)-import qualified Data.List as List (intercalate, isInfixOf)+import qualified Data.List as List (intercalate, isInfixOf, isPrefixOf) import qualified Data.Text as T import qualified Data.Text.IO as T import Data.Time.Clock (UTCTime(..), addUTCTime, secondsToDiffTime) import Data.Time.Calendar (fromGregorian) import Hledger.Flow.Common import qualified Hledger.Flow.RuntimeOptions as RT-import Hledger.Flow.Types (LogMessage (StdErr))+import Hledger.Flow.Types (HledgerConf (..), LogMessage (StdErr)) import qualified Path import qualified Path.IO as PathIO import System.Directory (doesFileExist)@@ -143,23 +143,25 @@   PathIO.setModificationTime target older   check "equal timestamps" False   PathIO.setModificationTime source (addUTCTime 0.75 older)-  check "subsecond differences alone do not trigger regeneration" False+  check "source newer within the same second" True    PathIO.removeFile source   check "source timestamp no longer available" True  testBatchOrder :: Test testBatchOrder = TestCase $ do-  secondStarted <- newEmptyMVar-  firstFinished <- newEmptyMVar-  let first = takeMVar secondStarted >> putMVar firstFinished () >> return (1 :: Int)-      second = putMVar secondStarted () >> return 2-      third = do-        finished <- tryTakeMVar firstFinished-        assertEqual "finish the current batch before starting the next" (Just ()) finished-        return 3-  result <- timeout 10000000 (parBatchedActions 2 [0] [first, second, third])-  assertEqual "preserve input order across parallel batches" (Just [0, 1, 2, 3]) result+  firstStarted <- newEmptyMVar+  thirdStarted <- newEmptyMVar+  concurrency <- newTVarIO (0, 0 :: Int)+  let track = bracket_+        (atomically $ modifyTVar' concurrency $ \(active, peak) -> (active + 1, max peak (active + 1)))+        (atomically $ modifyTVar' concurrency $ \(active, peak) -> (active - 1, peak))+      first = putMVar firstStarted () >> takeMVar thirdStarted >> return (1 :: Int)+      second = takeMVar firstStarted >> return 2+      third = putMVar thirdStarted () >> return 3+  result <- timeout 10000000 (parBatchedActions 2 [0] (map track [first, second, third]))+  readTVarIO concurrency >>= assertEqual "two simultaneous workers, both joined" (0, 2)+  assertEqual "refill a free worker while preserving input order" (Just [0, 1, 2, 3]) result  testBatchFailure :: Test testBatchFailure = TestLabel "batch failure cancels siblings and stops later batches" $@@ -181,5 +183,70 @@   tryTakeMVar stopped >>= assertEqual "cancel and join the running sibling" (Just ())   tryTakeMVar later >>= assertEqual "never start the next batch" Nothing +-- Status must name the config file hledger loads by itself, in hledger's search order.+testFindHledgerConf :: Test+testFindHledgerConf = TestLabel "hledger config lookup" $ TestCase $ withTestDir $ \base -> do+  let root = Path.toFilePath base+      found expected = do+        actual <- findHledgerConf Nothing+        expectedPath <- Directory.canonicalizePath expected+        assertEqual ("found " ++ expected) (Just (FoundConf expectedPath)) actual+  forM_ (["near/work", "home/work", "xdg/hledger"] :: [FilePath]) $ \dir ->+    Directory.createDirectoryIfMissing True (root </> dir)+  withEnv "HOME" (root </> "home") $ withEnv "XDG_CONFIG_HOME" (root </> "xdg") $ do+    writeFile (root </> "near" </> "hledger.conf") ""+    writeFile (root </> "home" </> "hledger.conf") ""+    writeFile (root </> "home" </> ".hledger.conf") ""+    writeFile (root </> "xdg" </> "hledger" </> "hledger.conf") ""+    Directory.withCurrentDirectory (root </> "near" </> "work") $ do+      found (root </> "near" </> "hledger.conf")+      given <- findHledgerConf (Just "other.conf")+      assertEqual "--hledger-conf wins" (Just (GivenConf "other.conf")) given+    unless (Sys.os == "mingw32") $ do+      Directory.withCurrentDirectory (root </> "home" </> "work") $ do+        found (root </> "home" </> ".hledger.conf")+        Directory.removeFile (root </> "home" </> ".hledger.conf")+        found (root </> "xdg" </> "hledger" </> "hledger.conf")++-- HUnit runs these cases sequentially; restore the variable even on failure.+withEnv :: String -> String -> IO a -> IO a+withEnv name value action = bracket (lookupEnv name) (maybe (unsetEnv name) (setEnv name)) $ \_ ->+  setEnv name value >> action++-- Paths come out resolved (macOS /private/var, symlinked homes), so match both spellings of HOME.+testHomeDirs :: Test+testHomeDirs = TestLabel "home folder spellings" $ TestCase $ unless (Sys.os == "mingw32") $ withTestDir $ \base -> do+  let root = Path.toFilePath base+  Directory.createDirectory (root </> "real")+  Directory.createDirectoryLink (root </> "real") (root </> "link")+  real <- Directory.canonicalizePath (root </> "real")+  forM_ ([root </> "link", root </> "link/", real ++ "/"] :: [FilePath]) $ \home -> withEnv "HOME" home $ do+    homes <- homeDirs+    assertEqual ("resolved path with HOME=" ++ home) ("~" </> "fin") (tildePath homes (real </> "fin"))+    assertEqual ("HOME's spelling with HOME=" ++ home) ("~" </> "fin") (tildePath homes (home </> "fin"))++testWriteGeneratedText :: Test+testWriteGeneratedText = TestLabel "write generated text" $ TestCase $ withTestDir $ \base -> do+  let dir = Path.toFilePath base </> "out"+      journal = dir </> "2024-include.journal"+  writeGeneratedText journal "first\n"+  T.readFile journal >>= assertEqual "create missing directory and file" "first\n"+  unless (Sys.os == "mingw32") $ do+    perms <- Directory.getPermissions journal+    Directory.setPermissions journal (Directory.setOwnerExecutable True perms)+    let real = dir </> "real.journal"+        link = dir </> "link.journal"+    T.writeFile real "old\n"+    Directory.createFileLink "real.journal" link+    writeGeneratedText link "through link\n"+    T.readFile real >>= assertEqual "replace the file behind a symlink" "through link\n"+    Directory.pathIsSymbolicLink link >>= assertBool "keep the symlink"+  writeGeneratedText journal "second\n"+  T.readFile journal >>= assertEqual "replace existing contents" "second\n"+  unless (Sys.os == "mingw32") $+    Directory.getPermissions journal >>= assertBool "keep existing permissions" . Directory.executable+  entries <- Directory.listDirectory dir+  assertBool ("no staged files left: " ++ show entries) (not (any (List.isPrefixOf ".") entries))+ tests :: Test-tests = TestList [testHledgerLookup, testFilterPaths, testFirstExistingFile, testVerboseTestFile, testNeedsRegeneration, testBatchOrder, testBatchFailure]+tests = TestList [testWriteGeneratedText, testHledgerLookup, testFindHledgerConf, testHomeDirs, testFilterPaths, testFirstExistingFile, testVerboseTestFile, testNeedsRegeneration, testBatchOrder, testBatchFailure]
test/Common/Unit.hs view
@@ -6,22 +6,29 @@  import Hledger.Flow.BaseDir (relativeToBase') import Hledger.Flow.Common-import Hledger.Flow.DocHelpers (docURL)+import Data.Aeson (eitherDecodeFileStrict)+import Hledger.Flow.DocHelpers (DocLink (..), docSlug, docURL) import Hledger.Flow.RuntimeOptions (RuntimeOptions (hledgerConf))-import qualified Data.List as List+import Hledger.Flow.Types (HledgerConf (..), HledgerInfo (hlVersion)) import qualified Data.Map.Strict as Map import qualified Data.Text as T import Path ((</>), reldir) import Test.HUnit-import TestHelpers (defaultOpts, testRoot)+import TestHelpers (defaultHlInfo, defaultOpts, testRoot) import qualified System.FilePath as FilePath  testDocURL :: Test testDocURL = TestCase $   assertEqual "Input files documentation URL"-    "https://github.com/apauley/hledger-flow/tree/master/docs#input-files"-    (docURL "input-files")+    "https://hledger-flow.github.io/go/input-files/"+    (docURL InputFiles) +testDocLinksHaveRedirects :: Test+testDocLinksHaveRedirects = TestCase $ do+  redirects <- either fail pure =<< eitherDecodeFileStrict "site/go-links.json" :: IO (Map.Map T.Text T.Text)+  let missing = [docSlug l | l <- [minBound .. maxBound], docSlug l `Map.notMember` redirects]+  assertEqual "Every DocLink needs a redirect in site/go-links.json" [] missing+ testShowCmdArgs :: Test testShowCmdArgs =   TestCase@@ -49,10 +56,47 @@         let optsWithoutConf = defaultOpts (testRoot </> [reldir|base|])         assertEqual "No hledger config should produce no hledger config args" [] (hledgerConfArgs optsWithoutConf) -        let optsWithConf = optsWithoutConf {hledgerConf = Just "/tmp/hledger.conf"}+        let optsWithConf = optsWithoutConf {hledgerConf = Just (GivenConf "/tmp/hledger.conf")}         assertEqual "Configured hledger config should produce --conf args" ["--conf", "/tmp/hledger.conf"] (hledgerConfArgs optsWithConf)++        let optsWithFoundConf = optsWithoutConf {hledgerConf = Just (FoundConf "/home/me/.hledger.conf")}+        assertEqual "hledger finds its default config itself" [] (hledgerConfArgs optsWithFoundConf)     ) +testHledgerFileArgument :: Test+testHledgerFileArgument = TestList+  [ "1.51 reads a literal filename" ~: argument "hledger 1.51.2, mac-aarch64" ~?= "April [1].csv",+    "1.52 expands file globs" ~: argument "hledger 1.52, mac-aarch64" ~?= "April [[-[]1].csv",+    "development versions expand file globs" ~: argument "hledger 1.99-g123abc" ~?= "April [[-[]1].csv"+  ]+  where+    argument version = hledgerFileArgument (defaultHlInfo {hlVersion = version}) "April [1].csv"++testHomePaths :: Test+testHomePaths =+  let homes = ["/home/me/", "/real/me"]+      native = T.pack . FilePath.joinPath+      quoted parts = "\"" <> native ("$HOME" : parts) <> "\""+      displayedProgram = if FilePath.isPathSeparator '\\' then "~\\'bin\\hledger'" else "~/bin/hledger"+   in TestList+        [ "home itself" ~: tildePath homes "/home/me" ~?= "~",+          "below home, trailing slash on HOME" ~: tildePath homes "/home/me/fin" ~?= FilePath.joinPath ["~", "fin"],+          "resolved spelling" ~: tildePath homes "/real/me/fin" ~?= FilePath.joinPath ["~", "fin"],+          "a sibling with the same prefix" ~: tildePath homes "/home/meg/fin" ~?= "/home/meg/fin",+          "outside home" ~: tildePath homes "/srv/fin" ~?= "/srv/fin",+          "command line" ~: showCmdArgsAt homes ["/home/me/bin/hledger", "--file", "/home/me/my fin/a.csv", "/srv/b"]+            ~?= displayedProgram <> " --file ~" <> T.singleton FilePath.pathSeparator <> "'" <> native ["my fin", "a.csv"] <> "' /srv/b",+          "posix command path" ~: homeShellPath Posix homes "/home/me/my fin" ~?= Just (quoted ["my fin"]),+          "home itself in a command" ~: homeShellPath Posix homes "/real/me" ~?= Just "\"$HOME\"",+          "powershell command path" ~: homeShellPath PowerShell homes "/home/me/Lee's fin" ~?= Just (quoted ["Lee's fin"]),+          "outside home in a command" ~: homeShellPath Posix homes "/srv/fin" ~?= Nothing,+          "posix specials fall back" ~: map (\c -> homeShellPath Posix homes ("/home/me/a" ++ [c])) "$`\"!" ~?= replicate 4 Nothing,+          "trailing backslash is special unless it separates paths" ~: homeShellPath Posix homes "/home/me/a\\"+            ~?= if FilePath.isPathSeparator '\\' then Just (quoted ["a"]) else Nothing,+          "powershell specials fall back" ~: map (\c -> homeShellPath PowerShell homes ("/home/me/a" ++ [c])) "$`\"\x201c\x201d\x201e" ~?= replicate 6 Nothing,+          "posix-only specials are fine in powershell" ~: homeShellPath PowerShell homes "/home/me/a!" ~?= Just (quoted ["a!"])+        ]+ testRelativeToBase :: Test testRelativeToBase =   TestCase@@ -109,19 +153,22 @@         assertEqual "Extract digits from text 2" expected2 actual2     ) +-- Grouping requires an ordering on the key alone.+data Entry = Entry Char Int deriving (Eq, Show)+ testGroupValuesBy :: Test-testGroupValuesBy =-  TestCase-    ( do-        let values = ["b2", "a1", "b1", "a2"]-        let grouped = groupValuesBy (take 1) values-        let normalize = Map.map List.sort-        let expected = Map.fromList [("a", ["a1", "a2"]), ("b", ["b1", "b2"])]-        assertEqual "Group values by key" (normalize expected) (normalize grouped)-    )+testGroupValuesBy = TestCase $ do+  let key (Entry k _) = k+      a1 = Entry 'a' 1+      a2 = Entry 'a' 2+      b1 = Entry 'b' 1+      b2 = Entry 'b' 2+  assertEqual "interleaved keys retain input order and duplicates"+    (Map.fromList [('a', [a1, a2]), ('b', [b2, b1, b2])])+    (groupValuesBy key [b2, a1, b1, a2, b2])  tests :: Test-tests = TestList [testDocURL, testShowCmdArgs, testHledgerConfArgs, testRelativeToBase, testExtractDigits, testGroupValuesBy, testTrimToolOutput]+tests = TestList [testDocURL, testDocLinksHaveRedirects, testShowCmdArgs, testHledgerConfArgs, testHledgerFileArgument, testHomePaths, testRelativeToBase, testExtractDigits, testGroupValuesBy, testTrimToolOutput]  testTrimToolOutput :: Test testTrimToolOutput =
test/Environment/Unit.hs view
@@ -6,16 +6,18 @@ import qualified Data.Text as T import Hledger.Flow.Environment import Hledger.Flow.RuntimeOptions+import Hledger.Flow.Types (HledgerConf (..)) import Path import Test.HUnit-import TestHelpers (defaultOpts, testRoot)+import qualified System.FilePath as FilePath+import TestHelpers (defaultHlInfo, defaultOpts, testRoot)  opts :: RuntimeOptions opts = defaultOpts (testRoot </> [reldir|fin|])  testImportLines :: Test testImportLines = TestCase $ do-  let out = renderSettings id (importSettings opts {importStartYear = Just 2024, batchSize = 20})+  let out = renderSettings id (importSettings [] defaultHlInfo opts {importStartYear = Just 2024, batchSize = 20})       line l = filter ((l <> " ") `T.isPrefixOf`) out   assertEqual "one line per setting" 8 (length out)   assertBool "full hledger path and version" (any ((T.pack (toFilePath [relfile|path/to/hledger|]) <> "  (1.2.3)") `T.isInfixOf`) (line "hledger"))@@ -26,16 +28,30 @@  testMissingHledger :: Test testMissingHledger = TestCase $ do-  let out = renderSettings (\v -> "<" <> v <> ">") (environmentSettings (sysInfo opts) Nothing Nothing (baseDir opts))+  let out = renderSettings (\v -> "<" <> v <> ">") (environmentSettings [] (sysInfo opts) Nothing Nothing)   assertBool "missing hledger styled as a problem" (any ("<not found" `T.isInfixOf`) out)   assertBool "only that line styled" (length (filter ("<" `T.isInfixOf`) out) == 1) +testHledgerConf :: Test+testHledgerConf = TestCase $ do+  let confLine c = filter ("hledger.conf " `T.isPrefixOf`) (renderSettings id (environmentSettings [] (sysInfo opts) Nothing c))+  assertBool "no config" (any ("  none" `T.isInfixOf`) (confLine Nothing))+  assertBool "given config" (any ("  /given.conf " `T.isInfixOf`) (confLine (Just (GivenConf "/given.conf"))))+  assertBool "found config is named" (any ("/home/me/.hledger.conf  (found by hledger)" `T.isInfixOf`) (confLine (Just (FoundConf "/home/me/.hledger.conf"))))++testHomePaths :: Test+testHomePaths = TestCase $ do+  let out = renderSettings id (importSettings [toFilePath testRoot] defaultHlInfo opts)+      line l = filter ((l <> " ") `T.isPrefixOf`) out+  assertBool "hledger under home" (any ((T.pack (FilePath.joinPath ["~", "path", "to", "hledger"]) <> "  (1.2.3)") `T.isInfixOf`) (line "hledger"))+  assertBool "ledger under home" (any ((T.pack (FilePath.joinPath ["~", "fin"])) `T.isSuffixOf`) (line "Ledger"))+ testReportTables :: Test testReportTables = TestCase $ do-  let out = renderSettings id (reportSettings opts {prettyReports = False, sequential = True})+  let out = renderSettings id (reportSettings [] defaultHlInfo opts {prettyReports = False, sequential = True})   assertBool "ascii tables" (any ("ASCII" `T.isInfixOf`) out)   assertBool "sequential" (any ("one file at a time" `T.isInfixOf`) out)   assertBool "no import settings" (not (any ("Start year" `T.isPrefixOf`) out))  tests :: Test-tests = TestList [testImportLines, testMissingHledger, testReportTables]+tests = TestList [testImportLines, testMissingHledger, testHledgerConf, testHomePaths, testReportTables]
test/ImportHelpers/Integration.hs view
@@ -59,6 +59,20 @@     [account </> [relfile|3-journal/2019/march.bank.journal|]]     (stageFiles root [reldir|3-journal|] inventory) +testRestoredSource :: Test+testRestoredSource = TestLabel "restore a source after discovery" $ TestCase $ withTestDir $ \root -> do+  let account = root </> [reldir|owner/bank/checking|]+      source = account </> [relfile|1-in/2026/march.txt|]+      csv = account </> [relfile|2-preprocessed/2026/march.csv|]+      journal = account </> [relfile|3-journal/2026/march.journal|]+  mapM_ touchFile [csv, journal]+  cached <- findImportFiles root+  touchFile source+  (files, orphans, journals) <- reconciliationInventory root (Just cached)+  assertEqual "keep both restored outputs" [] orphans+  assertEqual "include the restored journal" [journal] journals+  assertBool "use refreshed inventory for includes" (source `elem` files)+ testStagedOutput :: Test testStagedOutput = TestCase $ withSystemTempDir "hlflow-output" $ \dir -> do   let target = dir </> [relfile|march.bank.csv|]@@ -80,8 +94,18 @@   withStagedOutput target $ \staged -> T.writeFile (toFilePath staged) "complete"   T.readFile (toFilePath target) >>= assertEqual "publish successful retry" "complete" +testCannotAlias :: Test+testCannotAlias = TestCase $ do+  assertBool "distinct plain names" (cannotAlias ["2024-01.journal", "2024-02.journal", "April [1].journal"])+  mapM_ (\names -> assertBool ("may alias: " ++ show names) (not (cannotAlias names)))+    [ ["march.journal", "march.journal"],+      ["march.journal", "March.journal"],+      ["caf\233.journal", "other.journal"],+      ["PROGRA~1.journal", "other.journal"]+    ]+ tests :: Test-tests = TestList [testInventory, testStagedOutput, testTrashLock]+tests = TestList [testInventory, testRestoredSource, testStagedOutput, testTrashLock, testCannotAlias]  testTrashLock :: Test testTrashLock = TestCase $ withSystemTempDir "hlflow-trash" $ \root -> do
test/ReleaseNotice/Unit.hs view
@@ -8,9 +8,9 @@ import Data.Aeson (encode, object, (.=)) import Data.IORef import qualified Data.Text as T-import Data.Version (showVersion)+import Data.Version (makeVersion, showVersion) import Paths_hledger_flow (version)-import Hledger.Flow.ReleaseNotice (checkRelease, finishCommand, startCheck)+import Hledger.Flow.ReleaseNotice (ReleaseResult (..), checkRelease, finishCommand, renderUpdate, startCheck) import Path (toFilePath) import System.Directory (createDirectoryIfMissing) import System.Exit (ExitCode (ExitFailure))@@ -21,14 +21,22 @@  tests :: Test tests = TestLabel "release notice" $ TestList-  [ "ignore malformed, nonstable and non-newer releases" ~: TestCase $-      forM_ [Nothing, Just "invalid", Just "{\"tag_name\":\"v0\"}",-             Just (encode (object ["tag_name" .= ("v" ++ showVersion version)])),+  [ "invalid or missing responses cannot confirm release status" ~: TestCase $+      forM_ [Nothing, Just "invalid",              Just "{\"tag_name\":\"v999-rc1\"}", Just "{\"tag_name\":\"v999x\"}",              Just "{\"tag_name\":\"v999\",\"prerelease\":true}",              Just "{\"tag_name\":\"v999\",\"draft\":true}"] $ \body -> do         notice <- checkRelease (const $ pure body) (fail "unexpected ownership check")-        Nothing @=? notice+        CheckUnavailable @=? notice+  , "installed version matches or exceeds the latest stable release" ~: TestCase $+      forM_ ["v0", "v" ++ showVersion version] $ \tag -> do+        let response = Just (encode (object ["tag_name" .= tag]))+        notice <- checkRelease (const $ pure response) (fail "unexpected ownership check")+        UpToDate @=? notice+  , "upgrade notice keeps its meaning with colour disabled" ~: TestCase $ do+      let installed = T.pack (showVersion version)+      ("  ↑ hledger-flow 999 is available (installed " <> installed <> ").\n    upgrade advice")+        @=? renderUpdate False (makeVersion [999]) "upgrade advice"   , "verified Homebrew ownership and published formula" ~: TestCase $       withTestDir $ \dir -> do         let keg = toFilePath dir </> "Cellar" </> "hledger-flow" </> "1.2.3"@@ -42,7 +50,7 @@               | otherwise = assertFailure ("unexpected URL " ++ url) >> pure Nothing             check advice formula = do               notice <- checkRelease (fetch formula) (pure exe)-              assertBool ("expected " ++ T.unpack advice) (maybe False (T.isInfixOf advice) notice)+              UpdateAvailable (makeVersion [999, 10, 0]) advice @=? notice             published = Just "  tag = \"v999.10.0\"\n"             releases = "https://github.com/apauley/hledger-flow/releases/latest"         createDirectoryIfMissing True (keg </> "bin")@@ -53,7 +61,7 @@         writeFile receipt "{\"source\":{\"tap\":\"ferntrail/tap\"}}"         check releases Nothing         check releases (Just "  tag = \"v999.9.0\"\n")-        check "brew update && brew upgrade ferntrail/tap/hledger-flow" published+        check "Upgrade with: brew update && brew upgrade ferntrail/tap/hledger-flow" published   , "command finishes with worker still pending" ~: TestCase $ do       started <- newEmptyMVar       release <- newEmptyMVar@@ -63,7 +71,7 @@       failure <- timeout 1000000 $ try (finishCommand finish (throwIO (ExitFailure 37)))       Just () @=? success       Just (Left (ExitFailure 37)) @=? failure-      putMVar release Nothing+      putMVar release UpToDate   , "ready notice follows diagnostics and preserves the exit code" ~: TestCase $ do       events <- newIORef ([] :: [String])       let record msg = modifyIORef' events (++ [msg])
test/RulesTemplate/Unit.hs view
@@ -17,6 +17,11 @@       "hledger's special names renamed" ~: templateFields ["Date", "Description", "Amount", "Balance", "Status", "Code"]         ~?= ["date", "description", "amount", "bank_balance", "bank_status", "bank_code"],       "single amount column mapped" ~: templateFields ["Date", "Desc", "Transaction Amount"] ~?= ["date", "description", "amount"],+      "suffixes are unique even when present in the header" ~:+        templateFields ["Reference", "Reference", "Reference_2", "Reference"]+        ~?= ["reference", "reference_2", "reference_2_2", "reference_3"],+      "skip a suffix already used by an earlier column" ~:+        templateFields ["Reference_2", "Reference", "Reference"] ~?= ["reference_2", "reference", "reference_3"],       "debit and credit amounts left alone" ~: templateFields ["Date", "Desc", "Debit Amount", "Credit Amount"]         ~?= ["date", "description", "debit_amount", "credit_amount"],       "renamed columns explained" ~: fmap (T.isInfixOf "bank_balance")
+ test/RuntimeOptions/Unit.hs view
@@ -0,0 +1,23 @@+{-# LANGUAGE QuasiQuotes #-}++module RuntimeOptions.Unit (tests) where++import Hledger.Flow.RuntimeOptions+import qualified Hledger.Flow.Types as Types+import Path+import Test.HUnit+import TestHelpers (defaultOpts, testRoot)++tests :: Test+tests = TestCase $ do+  let base = testRoot </> [reldir|ledger|]+      run = [reldir|import/owner|]+      opts = (defaultOpts base) {importRunDir = run, batchSize = 19, prettyReports = False}+      flags o = (Types.verbose o, Types.sequential o, Types.prettyReports o)+  assertEqual "base directory" base (Types.baseDir opts)+  assertEqual "import directory" run (Types.importRunDir opts)+  assertEqual "batch size" 19 (Types.batchSize opts)+  assertEqual "verbose alone" (True, False, False) (flags opts {verbose = True})+  assertEqual "sequential alone" (False, True, False) (flags opts {sequential = True})+  assertEqual "pretty reports alone" (False, False, True) (flags opts {prettyReports = True})+  assertEqual "unrelated flags" (False, False, False) (flags opts {onlyNewFiles = True, showOptions = True})
test/Spec.hs view
@@ -14,6 +14,7 @@ import qualified PathHelpers.Unit import qualified ReleaseNotice.Unit import qualified RulesTemplate.Unit+import qualified RuntimeOptions.Unit import qualified Status.Integration import qualified Status.Unit import Test.HUnit@@ -34,6 +35,7 @@       Init.Integration.tests,       ReleaseNotice.Unit.tests,       RulesTemplate.Unit.tests,+      RuntimeOptions.Unit.tests,       Environment.Unit.tests     ] 
test/Status/Unit.hs view
@@ -71,7 +71,7 @@     map native ["import/me/mybank/JAN.CSV", "import/me/mybank/checking/3-journal/2026/mar.csv"]  atRoot :: LedgerStatus -> NextStep-atRoot = nextStep 2026 Posix (lsBaseDir blank)+atRoot = nextStep 2026 Posix [] (lsBaseDir blank)  summaryOf :: LedgerStatus -> T.Text summaryOf = nsSummary . atRoot@@ -96,17 +96,36 @@       "4 rules" ~: commandOf blank {lsAccounts = [acct {asWithoutRules = 1, asRulesFile = Nothing}]} ~?= Just "hledger-flow status --rules-template me/mybank/checking",       "4 summary" ~: summaryOf blank {lsAccounts = [acct {asWithoutRules = 1, asRulesFile = Nothing}]} ~?= "Create import/me/mybank/checking/mybank-checking.rules (rules for me/mybank/checking)",       "4 powershell literal account" ~:-        nsCommand (nextStep 2026 PowerShell (lsBaseDir blank) blank {lsAccounts = [acct {asWithoutRules = 1, asAccount = "me/Bob's Bank/Lee’s $(example)"}]})+        nsCommand (nextStep 2026 PowerShell [] (lsBaseDir blank) blank {lsAccounts = [acct {asWithoutRules = 1, asAccount = "me/Bob's Bank/Lee’s $(example)"}]})           ~?= Just "hledger-flow status --rules-template 'me/Bob''s Bank/Lee’’s $(example)'",       "4 skipped with construct" ~: commandOf blank {lsAccounts = [acct {asWithoutRules = 1, asConstruct = True}]} ~?= Just "hledger-flow report",       "5 import" ~: summaryOf blank {lsAccounts = [acct {asJournalsMissing = 1, asJournalsOutdated = 1}]} ~?= "Import 2 new or changed statements",       "5 import singular" ~: summaryOf blank {lsAccounts = [acct {asJournalsMissing = 1}]} ~?= "Import 1 new or changed statement",       "6 posix" ~: commandOf blank {lsAccounts = [acct], lsLedgerFile = Nothing} ~?= Just ("export LEDGER_FILE='" <> allYears <> "'"),-      "6 windows" ~: nsCommand (nextStep 2026 PowerShell (lsBaseDir blank) blank {lsAccounts = [acct], lsLedgerFile = Nothing}) ~?= Just ("$env:LEDGER_FILE = '" <> allYears <> "'"),+      "6 windows" ~: nsCommand (nextStep 2026 PowerShell [] (lsBaseDir blank) blank {lsAccounts = [acct], lsLedgerFile = Nothing}) ~?= Just ("$env:LEDGER_FILE = '" <> allYears <> "'"),       "6 set but missing" ~: nsDetail (atRoot blank {lsAccounts = [acct], lsLedgerFile = Just (LedgerFile "/nope.journal" False)}) ~?= ["LEDGER_FILE is set to /nope.journal, which doesn't exist."],       "7 done" ~: commandOf blank {lsAccounts = [acct]} ~?= Just "hledger-flow report"     ] +-- Readable text shows the home folder as ~; pasteable commands use "$HOME", which shells expand.+testHome :: Test+testHome =+  let homes = [toFilePath testRoot]+      elsewhere = testRoot </> [reldir|elsewhere|]+      next shell cwd s = nextStep 2026 shell homes cwd s+      unset = blank {lsAccounts = [acct], lsLedgerFile = Nothing}+      quoted parts = "\"" <> T.pack (FilePath.joinPath ("$HOME" : parts)) <> "\""+      dollarInPath = unset {lsBaseDir = testRoot </> [reldir|fin$|]}+   in TestList+        [ "posix export" ~: nsCommand (next Posix (lsBaseDir blank) unset) ~?= Just ("export LEDGER_FILE=" <> quoted ["fin", "all-years.journal"]),+          "powershell export" ~: nsCommand (next PowerShell (lsBaseDir blank) unset) ~?= Just ("$env:LEDGER_FILE = " <> quoted ["fin", "all-years.journal"]),+          "ledger argument" ~: nsCommand (next Posix elsewhere blank {lsAccounts = [acct]}) ~?= Just ("hledger-flow report " <> quoted ["fin"]),+          "summary" ~: nsSummary (next Posix elsewhere blank {lsAccounts = [acct {asWithoutRules = 1, asRulesFile = Nothing}]})+            ~?= T.pack ("Create " <> FilePath.joinPath ["~", "fin", "import", "me", "mybank", "checking", "mybank-checking.rules"]) <> " (rules for me/mybank/checking)",+          "special character falls back to the absolute path" ~: nsCommand (next Posix (lsBaseDir dollarInPath) dollarInPath)+            ~?= Just ("export LEDGER_FILE='" <> T.pack (toFilePath (lsBaseDir dollarInPath </> [relfile|all-years.journal|])) <> "'")+        ]+ testVersion :: Test testVersion =   TestList@@ -118,14 +137,14 @@ testRenderNoColour :: Test testRenderNoColour = TestCase $ do   let s = blank {lsAccounts = [acct {asWithoutRules = 1, asRulesFile = Nothing}]}-      out = renderStatus False Nothing s (atRoot s)+      out = renderStatus False [] s (atRoot s)   assertBool "account line" ("me/mybank/checking" `T.isInfixOf` out && "✗ no rules file" `T.isInfixOf` out)   assertBool "next step" ("Next: Create import/me/mybank/checking/mybank-checking.rules" `T.isInfixOf` out)   assertBool "no escapes" (not ("\ESC" `T.isInfixOf` out))  testRenderPending :: Test testRenderPending = TestCase $ do-  let render a = renderStatus False Nothing blank {lsAccounts = [a]} (atRoot blank)+  let render a = renderStatus False [] blank {lsAccounts = [a]} (atRoot blank)   assertBool "nothing imported yet" ("• 1 not imported" `T.isInfixOf` render acct {asJournalsMissing = 1})   assertBool "mixed" ("• 11 journals, 1 not imported, 2 outdated" `T.isInfixOf` render acct {asStatements = 12, asJournalsMissing = 1, asJournalsOutdated = 2})   assertBool "clean" ("✓ 1 journal" `T.isInfixOf` render acct)@@ -137,4 +156,4 @@       @?= ["accounts", "allYearsJournalExists", "baseDir", "environment", "hledger", "ledgerFile", "manualJournals", "misplaced", "next", "schemaVersion"]   _ -> assertFailure "status JSON is an object" -tests = TestList [testSuggest, testMisplacedUpperCase, testRules, testVersion, testRenderNoColour, testRenderPending, testJSONShape]+tests = TestList [testSuggest, testMisplacedUpperCase, testRules, testHome, testVersion, testRenderNoColour, testRenderPending, testJSONShape]
test/TestHelpers.hs view
@@ -51,7 +51,8 @@       importStartYear = Nothing,       onlyNewFiles = False,       hfVersion = versionInfo testSystemInfo,-      hledgerInfo = defaultHlInfo,+      hledgerExecutable = FlowTypes.hlPath defaultHlInfo,+      hledgerVersion = return (FlowTypes.hlVersion defaultHlInfo),       hledgerConf = Nothing,       sysInfo = testSystemInfo,       verbose = False,