packages feed

hledger-flow 0.18.0 → 0.19.0

raw patch · 29 files changed

+1891/−171 lines, 29 filesdep +aesondep +ansi-terminaldep +bytestringPVP ok

version bump matches the API change (PVP)

Dependencies added: aeson, ansi-terminal, bytestring, http-client, http-client-tls

API changes (from Hackage documentation)

+ Hledger.Flow.BaseDir: findBaseDir :: AbsDir -> IO (Maybe BaseDir)
+ Hledger.Flow.Common: Posix :: Shell
+ Hledger.Flow.Common: PowerShell :: Shell
+ Hledger.Flow.Common: configuredLedgerFile :: IO (Maybe (FilePath, FilePath))
+ Hledger.Flow.Common: data Shell
+ Hledger.Flow.Common: findHledger :: Maybe FilePath -> IO (Maybe HledgerInfo)
+ Hledger.Flow.Common: instance GHC.Classes.Eq Hledger.Flow.Common.Shell
+ Hledger.Flow.Common: instance GHC.Internal.Show.Show Hledger.Flow.Common.Shell
+ Hledger.Flow.Common: nativeShell :: Shell
+ Hledger.Flow.Common: posixLiteral :: Text -> Text
+ Hledger.Flow.Common: powerShellLiteral :: Text -> Text
+ Hledger.Flow.Common: shellArgument :: Shell -> Text -> Text
+ Hledger.Flow.Common: textFileNeedsUpdate :: FilePath -> Text -> IO Bool
+ Hledger.Flow.Common: trimToolOutput :: Text -> Text
+ Hledger.Flow.Environment: Setting :: Text -> Text -> Text -> Bool -> Setting
+ Hledger.Flow.Environment: [sFlags] :: Setting -> Text
+ Hledger.Flow.Environment: [sLabel] :: Setting -> Text
+ Hledger.Flow.Environment: [sProblem] :: Setting -> Bool
+ Hledger.Flow.Environment: [sValue] :: Setting -> Text
+ Hledger.Flow.Environment: coresSetting :: SystemInfo -> Setting
+ Hledger.Flow.Environment: data Setting
+ Hledger.Flow.Environment: environmentJSON :: SystemInfo -> Maybe FilePath -> Value
+ Hledger.Flow.Environment: environmentSettings :: SystemInfo -> Maybe HledgerInfo -> Maybe FilePath -> BaseDir -> [Setting]
+ Hledger.Flow.Environment: importSettings :: RuntimeOptions -> [Setting]
+ Hledger.Flow.Environment: renderSettings :: (Text -> Text) -> [Setting] -> [Text]
+ Hledger.Flow.Environment: reportSettings :: RuntimeOptions -> [Setting]
+ Hledger.Flow.Import.ImportHelpers: defaultRulesCandidates :: ImportDirs Abs -> IO [AbsFile]
+ Hledger.Flow.Import.ImportHelpers: importDirLines :: Path b Dir -> [Text]
+ Hledger.Flow.Import.ImportHelpers: rulesFileCandidates :: AbsFile -> ImportDirs Abs -> IO [AbsFile]
+ Hledger.Flow.Import.ImportHelpersTurtle: includesNeedUpdate :: BaseDir -> AbsDir -> [AbsFile] -> [AbsFile] -> IO Bool
+ Hledger.Flow.Import.Inventory: classifyGeneratedOutputs :: AbsDir -> [AbsFile] -> [AbsFile] -> IO ([AbsFile], [AbsFile])
+ Hledger.Flow.Import.Inventory: isManualJournal :: AbsDir -> AbsFile -> Bool
+ Hledger.Flow.Init: AlreadyLedger :: BaseDir -> InitOutcome
+ Hledger.Flow.Init: Created :: BaseDir -> Maybe BaseDir -> InitOutcome
+ Hledger.Flow.Init: InsideLedger :: BaseDir -> InitOutcome
+ Hledger.Flow.Init: data InitOutcome
+ Hledger.Flow.Init: initLedger :: AbsDir -> IO InitOutcome
+ Hledger.Flow.Init: instance GHC.Classes.Eq Hledger.Flow.Init.InitOutcome
+ Hledger.Flow.Init: instance GHC.Internal.Show.Show Hledger.Flow.Init.InitOutcome
+ Hledger.Flow.Init: runInit :: Maybe FilePath -> AbsDir -> AbsDir -> IO ()
+ Hledger.Flow.Internals: gitCommit :: String
+ Hledger.Flow.ReleaseNotice: checkRelease :: (String -> IO (Maybe ByteString)) -> IO FilePath -> IO (Maybe Text)
+ Hledger.Flow.ReleaseNotice: finishCommand :: IO () -> IO () -> IO ()
+ Hledger.Flow.ReleaseNotice: startCheck :: IO (Maybe Text) -> IO (IO ())
+ Hledger.Flow.ReleaseNotice: withReleaseNotice :: IO () -> IO ()
+ Hledger.Flow.RulesTemplate: looksLikeHeader :: [Text] -> Bool
+ Hledger.Flow.RulesTemplate: rulesTemplate :: Text -> Text -> FilePath -> Text -> Either Text Text
+ Hledger.Flow.RulesTemplate: rulesTemplateFor :: BaseDir -> Text -> IO (Either Text Text)
+ Hledger.Flow.RulesTemplate: splitHeader :: Text -> (Char, [Text])
+ Hledger.Flow.RulesTemplate: templateFields :: [Text] -> [Text]
+ Hledger.Flow.Status: AccountStatus :: Text -> Int -> [Integer] -> Maybe FilePath -> [FilePath] -> Int -> Bool -> Bool -> Int -> Int -> AccountStatus
+ Hledger.Flow.Status: Elsewhere :: Situation
+ Hledger.Flow.Status: EmptyDir :: Situation
+ Hledger.Flow.Status: InLedger :: BaseDir -> Situation
+ Hledger.Flow.Status: LedgerFile :: FilePath -> Bool -> LedgerFile
+ Hledger.Flow.Status: LedgerStatus :: BaseDir -> Maybe HledgerInfo -> Maybe LedgerFile -> [AccountStatus] -> [Misplaced] -> Int -> Bool -> Bool -> LedgerStatus
+ Hledger.Flow.Status: Misplaced :: FilePath -> FilePath -> Misplaced
+ Hledger.Flow.Status: NextStep :: Text -> Maybe Text -> [Text] -> Maybe Text -> NextStep
+ Hledger.Flow.Status: [asAccount] :: AccountStatus -> Text
+ Hledger.Flow.Status: [asConstruct] :: AccountStatus -> Bool
+ Hledger.Flow.Status: [asJournalsMissing] :: AccountStatus -> Int
+ Hledger.Flow.Status: [asJournalsOutdated] :: AccountStatus -> Int
+ Hledger.Flow.Status: [asPreprocess] :: AccountStatus -> Bool
+ Hledger.Flow.Status: [asRulesCandidates] :: AccountStatus -> [FilePath]
+ Hledger.Flow.Status: [asRulesFile] :: AccountStatus -> Maybe FilePath
+ Hledger.Flow.Status: [asStatements] :: AccountStatus -> Int
+ Hledger.Flow.Status: [asWithoutRules] :: AccountStatus -> Int
+ Hledger.Flow.Status: [asYears] :: AccountStatus -> [Integer]
+ Hledger.Flow.Status: [lfExists] :: LedgerFile -> Bool
+ Hledger.Flow.Status: [lfValue] :: LedgerFile -> FilePath
+ Hledger.Flow.Status: [lsAccounts] :: LedgerStatus -> [AccountStatus]
+ Hledger.Flow.Status: [lsAllYearsExists] :: LedgerStatus -> Bool
+ Hledger.Flow.Status: [lsBaseDir] :: LedgerStatus -> BaseDir
+ Hledger.Flow.Status: [lsHledger] :: LedgerStatus -> Maybe HledgerInfo
+ Hledger.Flow.Status: [lsLedgerFile] :: LedgerStatus -> Maybe LedgerFile
+ Hledger.Flow.Status: [lsManualJournals] :: LedgerStatus -> Int
+ Hledger.Flow.Status: [lsMisplaced] :: LedgerStatus -> [Misplaced]
+ Hledger.Flow.Status: [lsNeedsReconciliation] :: LedgerStatus -> Bool
+ Hledger.Flow.Status: [mpPath] :: Misplaced -> FilePath
+ Hledger.Flow.Status: [mpSuggestion] :: Misplaced -> FilePath
+ Hledger.Flow.Status: [nsCommand] :: NextStep -> Maybe Text
+ Hledger.Flow.Status: [nsDetail] :: NextStep -> [Text]
+ Hledger.Flow.Status: [nsDocs] :: NextStep -> Maybe Text
+ Hledger.Flow.Status: [nsSummary] :: NextStep -> Text
+ Hledger.Flow.Status: data AccountStatus
+ Hledger.Flow.Status: data LedgerFile
+ Hledger.Flow.Status: data LedgerStatus
+ Hledger.Flow.Status: data Misplaced
+ Hledger.Flow.Status: data NextStep
+ Hledger.Flow.Status: data Situation
+ Hledger.Flow.Status: hledgerVersionNumber :: Text -> Text
+ Hledger.Flow.Status: instance GHC.Classes.Eq Hledger.Flow.Status.AccountStatus
+ Hledger.Flow.Status: instance GHC.Classes.Eq Hledger.Flow.Status.LedgerFile
+ Hledger.Flow.Status: instance GHC.Classes.Eq Hledger.Flow.Status.Misplaced
+ Hledger.Flow.Status: instance GHC.Classes.Eq Hledger.Flow.Status.NextStep
+ Hledger.Flow.Status: instance GHC.Classes.Eq Hledger.Flow.Status.Situation
+ Hledger.Flow.Status: instance GHC.Internal.Show.Show Hledger.Flow.Status.AccountStatus
+ Hledger.Flow.Status: instance GHC.Internal.Show.Show Hledger.Flow.Status.LedgerFile
+ Hledger.Flow.Status: instance GHC.Internal.Show.Show Hledger.Flow.Status.LedgerStatus
+ Hledger.Flow.Status: instance GHC.Internal.Show.Show Hledger.Flow.Status.Misplaced
+ Hledger.Flow.Status: instance GHC.Internal.Show.Show Hledger.Flow.Status.NextStep
+ Hledger.Flow.Status: instance GHC.Internal.Show.Show Hledger.Flow.Status.Situation
+ Hledger.Flow.Status: misplacedFiles :: AbsDir -> [AbsFile] -> [Misplaced]
+ Hledger.Flow.Status: nextStep :: Integer -> Shell -> AbsDir -> LedgerStatus -> NextStep
+ Hledger.Flow.Status: plural :: Int -> Text -> Text
+ Hledger.Flow.Status: statementExtensions :: [String]
+ Hledger.Flow.Status: suggestPlacement :: [FilePath] -> FilePath
+ Hledger.Flow.Status.Gather: accountName :: AbsDir -> AbsFile -> Text
+ Hledger.Flow.Status.Gather: cachedStatement :: AbsFile -> IO (Maybe AbsFile)
+ Hledger.Flow.Status.Gather: classifyDir :: AbsDir -> IO Situation
+ Hledger.Flow.Status.Gather: gatherStatus :: Maybe HledgerInfo -> BaseDir -> IO LedgerStatus
+ Hledger.Flow.Status.Gather: hookExists :: AbsDir -> RelFile -> IO Bool
+ Hledger.Flow.Status.Gather: instance GHC.Classes.Eq Hledger.Flow.Status.Gather.JournalState
+ Hledger.Flow.Status.Render: nextStepHere :: LedgerStatus -> IO NextStep
+ Hledger.Flow.Status.Render: printNext :: Maybe FilePath -> BaseDir -> IO ()
+ Hledger.Flow.Status.Render: printStatus :: Maybe FilePath -> Maybe FilePath -> Bool -> BaseDir -> IO ()
+ Hledger.Flow.Status.Render: renderEnvironment :: Bool -> SystemInfo -> Maybe FilePath -> LedgerStatus -> Text
+ Hledger.Flow.Status.Render: renderNext :: Bool -> NextStep -> Text
+ Hledger.Flow.Status.Render: renderStatus :: Bool -> Maybe AbsDir -> LedgerStatus -> NextStep -> Text
+ Hledger.Flow.Status.Render: statusJSON :: SystemInfo -> Maybe FilePath -> LedgerStatus -> NextStep -> Value
+ Hledger.Flow.Status.Render: tildePath :: Maybe AbsDir -> FilePath -> FilePath
+ Hledger.Flow.Status.Render: useColor :: Handle -> IO Bool
- Hledger.Flow.Common: timeAndExitOnErr' :: HasVerbosity o => o -> TChan LogMessage -> Text -> [Text] -> (TChan LogMessage -> Text -> IO ()) -> (TChan LogMessage -> Text -> IO ()) -> ProcInput -> IO FullTimedOutput
+ Hledger.Flow.Common: timeAndExitOnErr' :: HasVerbosity o => o -> TChan LogMessage -> Text -> [Text] -> [Text] -> (TChan LogMessage -> Text -> IO ()) -> (TChan LogMessage -> Text -> IO ()) -> ProcInput -> IO FullTimedOutput

Files

ChangeLog.md view
@@ -28,6 +28,31 @@   existing destination belongs in release-procedure.md. --> +## 0.19.0++- New `init` and `status` commands. `status` lists your accounts, flags+  misplaced statements, and tells you when to import after changing rules,+  adding manual journals or deleting statements.+  Running `hledger-flow` alone does the same inside a ledger, and says how to+  start one elsewhere. See the [quick-start](https://github.com/apauley/hledger-flow/blob/master/QUICKSTART.md#import-your-first-statement).+- `status --json` gives scripts and AI agents the same facts and next step+  ([fields](https://github.com/apauley/hledger-flow/blob/master/docs/README.md#status-for-scripts-and-agents)).+  `status --rules-template owner/bank/account` prints a starter rules file+  from the latest statement or its matching preprocessed CSV.+- `--show-options` now prints the hledger-flow version and commit, the full+  path and version of hledger, and each setting with the flag that changes it.+  `status` always ends with these versions and paths, and `status --json` has+  them under `environment`.+- Interactive `import` and `report` runs can show an [upgrade notice](https://github.com/apauley/hledger-flow/blob/master/docs/README.md#release-notices) without waiting for the network. Set `HLEDGER_FLOW_NO_UPDATE_CHECK=1` to turn it off.+- `import` and `report` print a short summary and the next step. The report+  summary counts how many empty reports were skipped. Both `status` and+  `report` accept `~/` paths in `LEDGER_FILE`. Timings and progress lines+  moved to `--verbose`. Errors no longer show Haskell+  backtraces, and a failing hledger conversion names the rules file to check+  without echoing the whole statement.+- Linux x86-64 users can [build and install a strictly confined snap](https://github.com/apauley/hledger-flow/blob/master/snap/README.md)+  that includes the hledger 1.99.6 pre-release for imports, reports, and account scripts.+ ## 0.18.0  - If you keep handwritten transactions in a journal under `3-journal`
app/Main.hs view
@@ -1,6 +1,7 @@ {-# LANGUAGE CPP #-} {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE QuasiQuotes #-}+{-# LANGUAGE ScopedTypeVariables #-}  module Main where @@ -10,6 +11,7 @@ import Prelude hiding (putStrLn)  import qualified Options.Applicative as Opts (command)+import Options.Applicative.Help.Pretty (Doc, pretty, vsep) import Options.Applicative     ( auto,       argument,@@ -19,9 +21,9 @@       hsubparser,       info,       prefs,-      progDescDoc,-      showHelpOnEmpty,-      showHelpOnError,+      progDesc,+      footerDoc,+      hidden,       optional,       Alternative(many, (<|>)),       Parser,@@ -35,13 +37,26 @@       switch )  import Hledger.Flow.Common ( 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.RulesTemplate ( rulesTemplateFor ) import Hledger.Flow.Internals (versionInfo, systemInfo)-import Hledger.Flow.BaseDir ( determineBaseDir )+import Hledger.Flow.BaseDir ( determineBaseDir, findBaseDir ) import qualified Hledger.Flow.RuntimeOptions as RT+import Hledger.Flow.ReleaseNotice ( withReleaseNotice ) import Hledger.Flow.Reports ( generateReports ) import Hledger.Flow.Import.CSVImport ( importCSVs )  import qualified Data.Text.IO as T+import Control.Exception (SomeAsyncException (..), SomeException, catch, displayException, fromException, throwIO)+import Hledger.Flow.PathHelpers (PathException (..), turtleToAbsDir)+import System.Exit (ExitCode (..), exitWith)+import System.IO (hPutStrLn, hSetEncoding, stderr, stdout, utf8)+import System.IO.Error (ioeGetErrorString, isUserError) #ifdef mingw32_HOST_OS import System.IO.CodePage (withCP65001) #endif@@ -55,8 +70,15 @@                                  , asciiReports :: Bool                                  } deriving (Show) -data Command = Import ImportParams | Report ReportParams deriving (Show)+data StatusParams = StatusParams { statusDir :: Maybe FilePath+                                 , statusJson :: Bool+                                 , statusRulesTemplate :: Maybe String+                                 } deriving (Show) +newtype InitParams = InitParams { initDir :: Maybe FilePath } deriving (Show)++data Command = Import ImportParams | Report ReportParams | Status StatusParams | Init InitParams deriving (Show)+ data MainParams = MainParams { verbosity :: Int                              , hledgerPathOpt :: Maybe FilePath                              , hledgerConfOpt :: Maybe FilePath@@ -64,7 +86,7 @@                              , batchSize :: Maybe Int                              , sequential :: Bool                              } deriving (Show)-data BaseCommand = Version | Command { mainParams :: MainParams, command :: Command } deriving (Show)+data BaseCommand = Version | Command { mainParams :: MainParams, command :: Maybe Command } deriving (Show)  main :: IO () #ifdef mingw32_HOST_OS@@ -77,16 +99,87 @@  runCLI :: IO () runCLI = do+  -- Status and errors use non-ASCII symbols; a C locale must not make them fail.+  mapM_ (`hSetEncoding` utf8) [stdout, stderr]+  runCommand `catch` reportFatal++-- | Print uncaught errors as one plain message, without GHC's backtrace.+reportFatal :: SomeException -> IO ()+reportFatal e+  | Just (code :: ExitCode) <- fromException e = throwIO code+  | Just (SomeAsyncException _) <- fromException e = throwIO e+  | otherwise = do+      hPutStrLn stderr ("hledger-flow: " ++ fatalMessage e)+      exitWith (ExitFailure 1)++fatalMessage :: SomeException -> String+fatalMessage e+  | Just (pe :: PathException) <- fromException e = show pe+  | Just ioe <- fromException e, isUserError ioe = ioeGetErrorString ioe+  | otherwise = displayException e++runCommand :: IO ()+runCommand = do   let description = "An hledger workflow focusing on automated statement import and classification:\nhttps://github.com/apauley/hledger-flow#readme"-  cmd <- customExecParser (prefs (showHelpOnError <> showHelpOnEmpty))-    (info (helper <*> baseCommandParser) (headerDoc (Just description)))+  cmd <- customExecParser (prefs mempty)+    (info (helper <*> baseCommandParser) (headerDoc (Just description) <> footerDoc (Just topExamples)))   case cmd of     Version -> do       sysInfo <- systemInfo       T.putStrLn $ versionInfo sysInfo -    Command mainParams' (Import subParams) -> toRuntimeOptionsImport mainParams' subParams >>= importCSVs-    Command mainParams' (Report subParams) -> toRuntimeOptionsReport mainParams' subParams >>= generateReports+    Command mainParams' (Just (Import subParams)) ->+      withReleaseNotice $ (toRuntimeOptionsImport mainParams' subParams >>= importCSVs) `catch` reportFatal+    Command mainParams' (Just (Report subParams)) ->+      withReleaseNotice $ (toRuntimeOptionsReport mainParams' subParams >>= generateReports) `catch` reportFatal+    Command mainParams' (Just (Status subParams)) -> runStatus mainParams' subParams+    Command mainParams' (Just (Init subParams)) -> do+      cwd <- getCurrentDir+      dir <- maybe (return cwd) resolveDir' (initDir subParams)+      runInit (hledgerPathOpt mainParams') cwd dir+    Command mainParams' Nothing -> runBare mainParams' +-- | (name, one-line description) of each command, in display order.+commandSummaries :: [(String, String)]+commandSummaries =+  [ ("status", "Show what's in your ledger and what to do next"),+    ("init", "Start a new ledger in a folder"),+    ("import", "Turn statements into journals"),+    ("report", "Generate reports from your journals")+  ]++summaryOf :: String -> String+summaryOf name = maybe "" id (lookup name commandSummaries)++briefUsage :: Text.Text+briefUsage =+  "hledger-flow turns bank statements into hledger journals and reports.\n\nCommands:\n"+    <> Text.concat ["  " <> Text.justifyLeft width ' ' (Text.pack name) <> "   " <> Text.pack desc <> "\n" | (name, desc) <- commandSummaries]+    <> "\nRun `hledger-flow --help` for options and examples.\n"+  where+    width = maximum (map (length . fst) commandSummaries)++runStatus :: MainParams -> StatusParams -> IO ()+runStatus mainParams' params = do+  cwd <- getCurrentDir+  dir <- maybe (return cwd) (turtleToAbsDir 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+    Nothing -> printStatus (hledgerPathOpt mainParams') (hledgerConfOpt mainParams') (statusJson params) base++-- | Running hledger-flow with no command: say where we are and what to do.+runBare :: MainParams -> IO ()+runBare mainParams' = do+  situation <- getCurrentDir >>= classifyDir+  case situation of+    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 $+        briefUsage+          <> "\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 @@ -145,22 +238,53 @@ baseCommandParser = (Command <$> verboseParser <*> commandParser)   <|> flag' Version (long "version" <> short 'V' <> help "Display version information") -commandParser :: Parser Command-commandParser = fmap Import (subcommand "import" "Uses hledger with your own rules and/or scripts to convert electronic statements into categorised journal files" subcommandParserImport)-  <|> fmap Report (subcommand "report" "Generate Reports" subcommandParserReport)-+commandParser :: Parser (Maybe Command)+commandParser = optional $ hsubparser $+  subcommand "status" (Status <$> subcommandParserStatus)+    <> subcommand "init" (Init . InitParams <$> optional (argument str (metavar "DIR" <> help "The folder for the new ledger, created if missing. Defaults to the current folder.")))+    <> subcommand "import" (Import <$> subcommandParserImport)+    <> subcommand "report" (Report <$> subcommandParserReport)+    <> metavar "COMMAND"   where-    subcommand name desc parser =-      hsubparser (Opts.command name (info parser (progDescDoc (Just desc))) <> metavar name)+    subcommand name parser = Opts.command name (info parser (progDesc (summaryOf name) <> footerDoc (lines' <$> lookup name subcommandExamples))) +lines' :: [String] -> Doc+lines' = vsep . map pretty++topExamples :: Doc+topExamples = lines'+  [ "Examples:",+    "  hledger-flow init            Start a ledger in the current folder",+    "  hledger-flow status          See what's there and what to do next",+    "  hledger-flow import          Turn statements into journals",+    "  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"+  ]++subcommandExamples :: [(String, [String])]+subcommandExamples =+  [ ("status", ["Examples:", "  hledger-flow status --json", "  hledger-flow status --rules-template me/mybank/checking"]),+    ("init", ["Example:", "  hledger-flow init ~/finances"]),+    ("import", ["Examples:", "  hledger-flow import import/me/mybank", "  hledger-flow import --start-year current"]),+    ("report", ["Example:", "  hledger-flow report"])+  ]++subcommandParserStatus :: Parser StatusParams+subcommandParserStatus = StatusParams+  <$> optional (argument str (metavar "DIR" <> help "A folder inside the ledger. Defaults to the current folder."))+  <*> switch (long "json" <> help "Print the status as JSON, for scripts and agents")+  <*> optional (option str (long "rules-template" <> metavar "ACCOUNT" <> help "Print a starter rules file for owner/bank/account, built from its newest statement's header row"))+ verboseParser :: Parser MainParams verboseParser = MainParams   <$> (length <$> many (flag' () (long "verbose" <> short 'v' <> help "Print more verbose output")))   <*> 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" <> help "The hledger config file to pass to hledger --conf"))-  <*> switch (long "show-options" <> help "Print the options this program will run with")-  <*> optional (option auto (long "batch-size" <> metavar "SIZE" <> help ("Number of files to process in parallel. Must be positive. Default: " <> show defaultBatchSize <> ". Ignored during sequential processing.")))-  <*> switch (long "sequential" <> help "Disable parallel processing")+  <*> 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.")))+  <*> switch (long "sequential" <> hidden <> help "Disable parallel processing")  subcommandParserImport :: Parser ImportParams subcommandParserImport = ImportParams
hledger-flow.cabal view
@@ -5,7 +5,7 @@ -- see: https://github.com/sol/hpack  name:           hledger-flow-version:        0.18.0+version:        0.19.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> category:       Finance, Console@@ -31,16 +31,23 @@       Hledger.Flow.Common       Hledger.Flow.DateTime       Hledger.Flow.DocHelpers+      Hledger.Flow.Environment       Hledger.Flow.Import.CSVImport       Hledger.Flow.Import.ImportHelpers       Hledger.Flow.Import.ImportHelpersTurtle       Hledger.Flow.Import.Inventory       Hledger.Flow.Import.Types+      Hledger.Flow.Init       Hledger.Flow.Internals       Hledger.Flow.Logging       Hledger.Flow.PathHelpers+      Hledger.Flow.ReleaseNotice       Hledger.Flow.Reports+      Hledger.Flow.RulesTemplate       Hledger.Flow.RuntimeOptions+      Hledger.Flow.Status+      Hledger.Flow.Status.Gather+      Hledger.Flow.Status.Render       Hledger.Flow.Types   other-modules:       Paths_hledger_flow@@ -48,13 +55,18 @@       src   ghc-options: -Wall   build-depends:-      async >=2.2.5 && <2.3+      aeson >=2.2.5 && <2.3+    , ansi-terminal >=1.1.5 && <1.2+    , async >=2.2.5 && <2.3     , base >=4.20 && <4.22+    , bytestring ==0.12.*     , containers ==0.7.*     , directory >=1.3.8.5 && <1.4     , exceptions >=0.10.9 && <0.11     , filepath >=1.5.4.0 && <1.6     , gitrev >=1.3.1 && <1.4+    , http-client >=0.7.19 && <0.8+    , http-client-tls >=0.3.6.4 && <0.4     , path >=0.9.6 && <0.10     , path-io >=1.8.2 && <1.9     , process >=1.6.26.1 && <1.7@@ -77,6 +89,7 @@     , hledger-flow     , optparse-applicative >=0.18.1.0 && <0.20     , path >=0.9.6 && <0.10+    , path-io >=1.8.2 && <1.9     , text >=2.1.3 && <2.2   default-language: Haskell2010   if os(windows)@@ -94,8 +107,14 @@       CSVImport.ImportHelperTurtleTests       CSVImport.Integration       CSVImport.Unit+      Environment.Unit       ImportHelpers.Integration+      Init.Integration       PathHelpers.Unit+      ReleaseNotice.Unit+      RulesTemplate.Unit+      Status.Integration+      Status.Unit       TestHelpers       Paths_hledger_flow   hs-source-dirs:@@ -103,6 +122,7 @@   ghc-options: -threaded -rtsopts -with-rtsopts=-N   build-depends:       HUnit >=1.6.2.0 && <1.7+    , aeson >=2.2.5 && <2.3     , base >=4.20 && <4.22     , containers ==0.7.*     , directory >=1.3.8.5 && <1.4
src/Hledger/Flow/BaseDir.hs view
@@ -3,6 +3,7 @@  module Hledger.Flow.BaseDir   ( determineBaseDir,+    findBaseDir,     relativeToBase,     relativeToBase',     turtleBaseDir,@@ -38,13 +39,20 @@  determineBaseDirFromStartDir' :: (MonadIO m, MonadThrow m) => AbsDir -> AbsDir -> m (BaseDir, RunDir) determineBaseDirFromStartDir' startDir possibleBaseDir = do-  Control.Monad.when (parent possibleBaseDir == possibleBaseDir) $ throwM (MissingBaseDir startDir)-  foundBaseDir <- doesDirExist $ possibleBaseDir </> [reldir|import|]-  if foundBaseDir-    then do-      runDir <- limitRunDir possibleBaseDir startDir-      return (possibleBaseDir, runDir)-    else determineBaseDirFromStartDir' startDir $ parent possibleBaseDir+  found <- liftIO $ findBaseDir possibleBaseDir+  case found of+    Nothing -> throwM (MissingBaseDir startDir)+    Just bd -> do+      runDir <- limitRunDir bd startDir+      return (bd, runDir)++-- | The nearest directory at or above the start directory that contains @import/@.+findBaseDir :: AbsDir -> IO (Maybe BaseDir)+findBaseDir dir = do+  found <- doesDirExist $ dir </> [reldir|import|]+  if found+    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
src/Hledger/Flow/Common.hs view
@@ -4,6 +4,7 @@ module Hledger.Flow.Common where  import Control.Concurrent (threadDelay)+import Control.Exception (SomeException, try) import Control.Monad (filterM, forM_, when) import Control.Monad.IO.Class (MonadIO, liftIO) import Control.Concurrent.Async (concurrently, mapConcurrently)@@ -30,6 +31,7 @@ import qualified Path import qualified Path.IO as Path import qualified System.Directory as Directory+import System.Environment (lookupEnv) import qualified System.FilePath as FilePath import System.Exit (ExitCode (..), exitWith) import System.IO (hClose)@@ -62,6 +64,20 @@                   <> "There are a number of installation options on the hledger website: https://hledger.org/download.html"           ioError (userError msg) +-- | The hledger to use, or Nothing when it can't be found or run. Never exits.+findHledger :: Maybe FilePath -> IO (Maybe HledgerInfo)+findHledger pathOption = do+  found <- case pathOption of+    Just h -> do+      exists <- Directory.doesFileExist h+      if exists then Just <$> Path.resolveFile' h else return Nothing+    Nothing -> Directory.findExecutable "hledger" >>= traverse Path.resolveFile'+  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)+ hledgerVersionFromPath :: AbsFile -> IO T.Text hledgerVersionFromPath hlp = do   let command = (Process.proc (toFilePath hlp) ["--version"])@@ -93,10 +109,34 @@ escapeArg :: T.Text -> T.Text escapeArg a   | not (T.null a) && T.all safe a = a-  | otherwise = "'" <> T.replace "'" "'\"'\"'" a <> "'"+  | otherwise = posixLiteral a   where     safe c = isAscii c && (isAlphaNum c || c `elem` ("-_./:=+,@%" :: String)) +-- | The shell used for copyable commands on this platform.+data Shell = Posix | PowerShell deriving (Eq, Show)++nativeShell :: Shell+nativeShell = if Sys.os == "mingw32" then PowerShell else Posix++shellArgument :: Shell -> T.Text -> T.Text+shellArgument Posix arg = escapeArg arg+shellArgument PowerShell arg+  | not (T.null arg) && T.all simple arg = arg+  | otherwise = powerShellLiteral arg+  where+    simple c = isAscii c && (isAlphaNum c || c `elem` ("-_./" :: String))++posixLiteral :: T.Text -> T.Text+posixLiteral arg = "'" <> T.replace "'" "'\"'\"'" arg <> "'"++powerShellLiteral :: T.Text -> T.Text+powerShellLiteral arg = "'" <> T.concatMap escape arg <> "'"+  where+    -- PowerShell also recognises typographic single quotes as delimiters.+    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 exitStatus ch = errExit' exitStatus (channelErrLn ch) @@ -162,7 +202,7 @@   (TChan LogMessage -> T.Text -> IO ()) ->   ProcInput ->   IO FullTimedOutput-timeAndExitOnErr opts ch cmdLabel = timeAndExitOnErr' opts ch cmdLabel []+timeAndExitOnErr opts ch cmdLabel = timeAndExitOnErr' opts ch cmdLabel [] []  timeAndExitOnErr' ::   (HasVerbosity o) =>@@ -170,26 +210,44 @@   TChan LogMessage ->   T.Text ->   [T.Text] ->+  [T.Text] ->   (TChan LogMessage -> T.Text -> IO ()) ->   (TChan LogMessage -> T.Text -> IO ()) ->   ProcInput ->   IO FullTimedOutput-timeAndExitOnErr' opts ch cmdLabel extraCmdLabels stdoutLogger stderrLogger input@(cmd, args, _) = do+timeAndExitOnErr' opts ch cmdLabel extraCmdLabels hints stdoutLogger stderrLogger input@(cmd, args, _) = do   let action = runProcessCaptured input-  timed@((ec, stdOut, stdErr), _) <- logTimedAction opts ch cmdLabel extraCmdLabels cmd args stdoutLogger stderrLogger action+  -- On failure the error output goes into the (trimmed) message, not straight to the console.+  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-      let msgOut = descriptiveOutput "Standard output" stdOut-      let msgErr = descriptiveOutput "Error output" stdErr-+      let msgOut = descriptiveOutput "Standard output" (trimToolOutput stdOut)+      let msgErr = descriptiveOutput "Error output" (trimToolOutput stdErr)       let exitMsg =-            "\n=== Begin Error: " <> cmdLabel <> " ===\nExternal command:\n"-              <> cmdText <> "\nExit code " <> T.pack (show i) <> "\n" <> msgOut <> msgErr-              <> "=== End Error: " <> cmdLabel <> " ===\n"+            "Failed " <> cmdLabel <> " (exit code " <> T.pack (show i) <> ").\n"+              <> "Command: " <> cmdText <> "\n" <> msgOut <> msgErr+              <> T.unlines hints       errExit i ch exitMsg timed-    ExitSuccess -> return timed+    ExitSuccess -> do+      stderrLogger ch stdErr+      return timed +-- | Keep a tool's output readable: at most 20 lines of at most 300 characters.+-- hledger can echo a whole statement on one line when parsing fails.+trimToolOutput :: T.Text -> T.Text+trimToolOutput txt+  | length ls <= maxLines && all ((<= maxWidth) . T.length) ls = txt+  | otherwise = T.intercalate "\n" (map cut (take maxLines ls) ++ dropped) <> (if "\n" `T.isSuffixOf` txt then "\n" else "")+  where+    ls = T.lines txt+    maxLines = 20+    maxWidth = 300+    cut l = if T.length l > maxWidth then T.take maxWidth l <> "…" else l+    dropped = case length ls - maxLines of+      n | n > 0 -> ["… (" <> T.pack (show n) <> " more lines)"]+      _ -> []+ parAwareActions :: (HasSequential o, HasBatchSize o) => o -> [IO a] -> IO [a] parAwareActions opts = if (sequential opts) then sequence else parBatchedActions (batchSize opts) [] @@ -250,6 +308,19 @@ allYearsFileName :: FilePath allYearsFileName = "all-years" <.> "journal" +-- Keep the configured spelling for diagnostics and expand a leading ~ for IO.+configuredLedgerFile :: IO (Maybe (FilePath, FilePath))+configuredLedgerFile = do+  value <- lookupEnv "LEDGER_FILE"+  case value of+    Just path | not (null path) -> do+      resolved <- case path of+        "~" -> Directory.getHomeDirectory+        '~' : sep : rest | FilePath.isPathSeparator sep -> (FilePath.</> rest) <$> Directory.getHomeDirectory+        _ -> return path+      return (Just (path, resolved))+    _ -> return Nothing+ directivesFile :: (HasBaseDir o) => o -> AbsFile directivesFile opts = baseDir opts Path.</> [relfile|directives.journal|] @@ -279,8 +350,11 @@  writeTextMap :: Map.Map FilePath T.Text -> IO () writeTextMap files = forM_ (Map.toList files) $ \(path, contents) -> do-  current <- tryIOError (T.readFile path)-  when (either (const True) (/= contents) current) $ T.writeFile path contents+  changed <- textFileNeedsUpdate path contents+  when changed $ T.writeFile path contents++textFileNeedsUpdate :: FilePath -> T.Text -> IO Bool+textFileNeedsUpdate path contents = either (const True) (/= contents) <$> tryIOError (T.readFile path)  listOwners :: HasBaseDir o => o -> IO [RelDir] listOwners opts = do
+ src/Hledger/Flow/Environment.hs view
@@ -0,0 +1,103 @@+{-# LANGUAGE OverloadedStrings #-}++-- | The versions, paths and settings a command runs with, for debugging and bug reports.+module Hledger.Flow.Environment+  ( Setting (..),+    environmentSettings,+    coresSetting,+    importSettings,+    reportSettings,+    renderSettings,+    environmentJSON,+  )+where++import Data.Aeson (Value, object, (.=))+import qualified Data.Text as T+import Data.Version (showVersion)+import Hledger.Flow.Internals (SystemInfo (..), gitCommit)+import Hledger.Flow.RuntimeOptions+import Hledger.Flow.Status (hledgerVersionNumber)+import Hledger.Flow.Types (BaseDir, HledgerInfo (..))+import Path (toFilePath)+import Paths_hledger_flow (version)+import System.FilePath (dropTrailingPathSeparator)++-- | One line: a label, its value and the flags that change it.+data Setting = Setting {sLabel :: T.Text, sValue :: T.Text, sFlags :: T.Text, sProblem :: Bool}++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 =+  [ 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"+      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))) ""+  ]++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)++parallelism :: RuntimeOptions -> Setting+parallelism o = setting "Parallelism" value "--batch-size, --sequential"+  where+    sys = sysInfo o+    value+      | 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+    ++ [ 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",+         parallelism o+       ]+  where+    runFrom = case dropTrailingPathSeparator (toFilePath (importRunDir o)) of+      "." -> ". (whole ledger)"+      dir -> T.pack dir++reportSettings :: RuntimeOptions -> [Setting]+reportSettings o =+  optsEnvironment o+    ++ [ setting "Tables" (if prettyReports o then "pretty" else "ASCII") "--ascii-reports",+         parallelism o+       ]++-- | Aligned lines; @problem@ styles values that need attention, such as a missing hledger.+renderSettings :: (T.Text -> T.Text) -> [Setting] -> [T.Text]+renderSettings problem settings = map line settings+  where+    width f = maximum (0 : map (T.length . f) settings)+    -- A long value without flags, such as the ledger path, mustn't push the flags column right.+    valueWidth = maximum (0 : [T.length (sValue s) | s <- settings, not (T.null (sFlags s))])+    line s =+      let value = T.justifyLeft valueWidth ' ' (sValue s)+          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 sys conf =+  object+    [ "hledgerFlowVersion" .= showVersion version,+      "commit" .= gitCommit,+      "compiler" .= (compilerName sys <> " " <> showVersion (compilerVersion sys)),+      "os" .= os sys,+      "arch" .= arch sys,+      "cores" .= cores sys,+      "availableCores" .= availableCores sys,+      "hledgerConf" .= conf+    ]++tshow :: Show a => a -> T.Text+tshow = T.pack . show
src/Hledger/Flow/Import/CSVImport.hs view
@@ -9,10 +9,10 @@ import Control.Concurrent.STM import Control.Monad import Data.Maybe (fromMaybe)-import qualified Data.Map.Strict as Map 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)@@ -31,19 +31,45 @@ import System.IO (hClose) import qualified System.Info as Sys import qualified System.Process as Process-import System.FilePath (dropExtensions, dropTrailingPathSeparator, takeExtension, (<.>), (</>))+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 Prelude hiding (putStrLn, take, writeFile)  type FileWasGenerated = Bool  importCSVs :: RuntimeOptions -> IO () importCSVs opts = withConsoleLogging $ \ch -> do-  when (showOptions opts) (channelOutLn ch (T.pack (show opts)))+  when (showOptions opts) (channelOutLn ch (T.unlines (renderSettings id (importSettings opts))))   logVerbose opts ch "Starting import"   (journals, diff) <- timeAction $ importCSVs' opts ch-  let generatedJournals = filter snd journals-  channelOutLn ch $ "Imported " <> T.pack (show (length generatedJournals)) <> "/" <> T.pack (show (length journals)) <> " journals in " <> T.pack (show diff)+  let importRoot = baseDir opts Path.</> [Path.reldir|import|]+      effectiveDir = effectiveRunDir (baseDir opts) (importRunDir opts)+      scope = T.pack (dropTrailingPathSeparator (relativeToBase opts (Path.toFilePath effectiveDir)))+      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+importSummary scope total generated accounts+  | total == 0 = "No statements to import in " <> scope <> "."+  | generated == total = "Imported " <> plural total "statement" <> " for " <> accountsText <> "."+  | otherwise =+      "Imported " <> T.pack (show generated) <> " of " <> plural total "statement" <> " for " <> accountsText+        <> " (" <> T.pack (show (total - generated)) <> " already up to date)."+  where+    accountsText+      | length accounts <= 3 = T.intercalate ", " accounts+      | otherwise = plural (length accounts) "account"+ importCSVs' :: RuntimeOptions -> TChan FlowTypes.LogMessage -> IO [(AbsFile, FileWasGenerated)] importCSVs' opts ch = do   let effectiveDir = effectiveRunDir (baseDir opts) (importRunDir opts)@@ -51,7 +77,7 @@       selected = inImportScope effectiveDir (fromMaybe 0 (importStartYear opts))   let startYearMsg = maybe " " (\y -> " (for the year " <> T.pack (show y) <> " and onwards) ") (importStartYear opts)   let dirText = T.pack (Path.toFilePath effectiveDir)-  channelOutLn ch $ "Collecting input files" <> startYearMsg <> "from " <> dirText+  logVerbose opts ch $ "Collecting input files" <> startYearMsg <> "from " <> dirText   (inventory, diff) <- timeAction $ findImportFiles importRoot   let inputFiles = filter selected (stageFiles importRoot [Path.reldir|1-in|] inventory)       fileCount = length inputFiles@@ -61,28 +87,20 @@     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]) ()-  channelOutLn ch $ "Found " <> T.pack (show fileCount) <> " input files" <> startYearMsg <> "in " <> T.pack (show diff) <> ". Proceeding with import..."+  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)    -- 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-  expected <- expectedOutputs (stageFiles importRoot [Path.reldir|1-in|] refreshed)-  let stageWithExtension stage ext = filter ((== ext) . takeExtension . Path.toFilePath) (stageFiles importRoot stage refreshed)-      journals = stageWithExtension [Path.reldir|3-journal|] ".journal"-      generated = stageWithExtension [Path.reldir|2-preprocessed|] ".csv" ++ journals-  classified <- forM generated $ \file -> do-    key <- canonicalFile file-    return (file, Map.member key expected)-  let orphans = [file | (file, False) <- classified, selected file]-      journalsOnDisk = [file | (file, True) <- classified, takeExtension (Path.toFilePath file) == ".journal"]-  forM_ orphans $ \file -> do+  (orphans, journalsOnDisk) <- classifyGeneratedOutputs importRoot refreshed (stageFiles importRoot [Path.reldir|1-in|] refreshed)+  forM_ (filter selected orphans) $ \file -> do     target <- moveToTrash file     channelOutLn ch $ "Moved orphaned output " <> relative file <> " to " <> relative target   (_, writeIncludeTime1) <- timeAction $ writeIncludesFromInventory opts ch importRoot refreshed journalsOnDisk   (_, writeIncludeTime2) <- timeAction $ writeToplevelAllYearsInclude opts   let includeGenTime = journalFindTime + writeIncludeTime1 + writeIncludeTime2-  channelOutLn ch $ "Wrote include files for " <> T.pack (show (length journalsOnDisk)) <> " generated journals in " <> T.pack (show includeGenTime)+  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)@@ -145,16 +163,12 @@       -- Windows PowerShell -File treats construct's literal "-" as an option.       -- Invoke the script with literal strings and propagate its exit status.       let command = "[Console]::OutputEncoding = New-Object Text.UTF8Encoding($false); & "-            <> T.unwords (map literal (T.pack path : args))+            <> T.unwords (map powerShellLiteral (T.pack path : args))             <> "; if (!$?) { if ($LASTEXITCODE) { exit $LASTEXITCODE }; exit 1 }"       return (T.pack shell, ["-NoProfile", "-NonInteractive", "-OutputFormat", "Text", "-Command", command])   | otherwise = return (T.pack path, args)   where     path = Path.toFilePath script-    -- PowerShell also recognises typographic single quotes as delimiters.-    literal s = "'" <> T.concatMap escape s <> "'"-    escape c | c == '\'' || (c >= '\x2018' && c <= '\x201b') = T.replicate 2 (T.singleton c)-             | otherwise = T.singleton c  logNewFileSkip :: RuntimeOptions -> TChan FlowTypes.LogMessage -> T.Text -> AbsFile -> IO () logNewFileSkip opts ch logIdentifier absTarget =@@ -190,7 +204,8 @@       let args = hledgerConfArgs opts ++ ["print", "--explicit"] ++ directivesArgs ++ csvArgs        let cmdLabel = "importing '" <> T.pack relCSV <> "' using rules file '" <> T.pack relRules <> "'"-      ((_, stdOut, _), _) <- timeAndExitOnErr opts ch cmdLabel dummyLogger channelErr (hledger, args, Nothing)+      let hints = ["Check the skip, fields and separator lines in " <> T.pack relRules <> ".", "Docs: " <> docURL "rules-files"]+      ((_, stdOut, _), _) <- timeAndExitOnErr' opts ch cmdLabel [] hints dummyLogger channelErr (hledger, args, Nothing)       T.writeFile (Path.toFilePath journalOut) stdOut     Nothing ->       do@@ -203,26 +218,6 @@                 <> docURL "rules-files"         errExit 1 ch msg () -rulesFileCandidates :: AbsFile -> ImportDirs Path.Abs -> IO [AbsFile]-rulesFileCandidates csv dirs = do-  let bank = importDirLines (bankDir dirs)-      account = importDirLines (accountDir dirs)-  accountRule <- rulesName (bank ++ account)-  bankRule <- rulesName bank-  overrides <- if "rfo" `T.isPrefixOf` suffix-    then do-      name <- rulesName [suffix]-      return [dir Path.</> name | dir <- [accountDir dirs, bankDir dirs, importDir dirs]]-    else return []-  return (overrides ++ [accountDir dirs Path.</> accountRule, importDir dirs Path.</> bankRule])-  where-    stem = dropExtensions (Path.toFilePath (Path.filename csv))-    suffix = snd (T.breakOnEnd "_" (T.pack stem))-    rulesName parts = Path.parseRelFile (T.unpack (T.intercalate "-" parts) <.> "rules")--importDirLines :: Path.Path b Path.Dir -> [T.Text]-importDirLines = T.splitOn "\n" . T.pack . dropTrailingPathSeparator . Path.toFilePath . Path.dirname- hookDirArgs :: ImportDirs b -> [T.Text] hookDirArgs dirs = map (T.concat . importDirLines) [bankDir dirs, accountDir dirs, ownerDir dirs] @@ -252,7 +247,7 @@     Just stdinPipe <- return input     Just stdoutPipe <- return output     hClose stdinPipe-    _ <- timeAndExitOnErr' opts ch cmdLabel [constructCmdText] channelOut channelErr+    _ <- timeAndExitOnErr' opts ch cmdLabel [constructCmdText] [] channelOut channelErr       (hledger, args, Just stdoutPipe)     hClose stdoutPipe     status <- Process.waitForProcess handle
src/Hledger/Flow/Import/ImportHelpers.hs view
@@ -1,7 +1,7 @@ {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE QuasiQuotes #-} -module Hledger.Flow.Import.ImportHelpers (withStagedOutput, extractImportDirs, groupIncludesUpTo, includeFileName) where+module Hledger.Flow.Import.ImportHelpers (withStagedOutput, extractImportDirs, groupIncludesUpTo, includeFileName, rulesFileCandidates, defaultRulesCandidates, importDirLines) where  import qualified Data.Map.Strict as Map import Data.Maybe (fromMaybe)@@ -12,7 +12,9 @@ import Hledger.Flow.PathHelpers (AbsFile, RelDir, RelFile, pathSize) import Path import qualified Path.IO as PathIO-import System.FilePath (dropTrailingPathSeparator)+import Control.Monad.Catch (MonadThrow)+import System.FilePath (dropExtensions, 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 ()@@ -67,3 +69,32 @@ includeFileName f = do   let includeFile = (dropTrailingPathSeparator . toFilePath . dirname . parent) f ++ "-include.journal"   fromMaybe [relfile|unknown-include.journal|] $ parseRelFile includeFile++-- | Rules files for a statement in lookup order: statement-specific overrides,+-- then the account's default rules files.+rulesFileCandidates :: AbsFile -> ImportDirs Abs -> IO [AbsFile]+rulesFileCandidates csv dirs = do+  overrides <- if "rfo" `T.isPrefixOf` 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))++-- | The account-level and bank-level rules files, in lookup order.+defaultRulesCandidates :: ImportDirs Abs -> IO [AbsFile]+defaultRulesCandidates dirs = do+  let bank = importDirLines (bankDir dirs)+      account = importDirLines (accountDir dirs)+  accountRule <- rulesName (bank ++ account)+  bankRule <- rulesName bank+  return [accountDir dirs </> accountRule, importDir dirs </> bankRule]++rulesName :: MonadThrow m => [T.Text] -> m RelFile+rulesName parts = parseRelFile (T.unpack (T.intercalate "-" parts) FilePath.<.> "rules")++importDirLines :: Path b Dir -> [T.Text]+importDirLines = T.splitOn "\n" . T.pack . dropTrailingPathSeparator . toFilePath . dirname
src/Hledger/Flow/Import/ImportHelpersTurtle.hs view
@@ -5,6 +5,7 @@ module Hledger.Flow.Import.ImportHelpersTurtle   ( extraIncludesForFile,     includePreamble,+    includesNeedUpdate,     toIncludeFiles,     toIncludeLine,     writeIncludesFromInventory,@@ -14,14 +15,14 @@ where  import Control.Concurrent.STM (TChan)-import Control.Monad (unless)+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, relativeToBase', turtleBaseDir)-import Hledger.Flow.Common (allYearsFileName, directivesFile, filterPaths, groupValuesBy, writeFiles, writeFiles')+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@@ -44,9 +45,14 @@ 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 m = do-  preMap <- extraIncludes opts ch (Map.keys m) ["opening.journal"] ["pre-import.journal"] []-  postMap <- extraIncludes opts ch (Map.keys m) ["closing.journal"] ["post-import.journal"] ["prices.journal"]+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@@ -71,15 +77,27 @@ includePreamble :: T.Text includePreamble = "### Generated by hledger-flow - DO NOT EDIT ###\n" -writeFileMap :: (HasBaseDir o, HasVerbosity o) => o -> TChan LogMessage -> (TurtleFileBundle, TurtleFileBundle) -> IO [FilePath]-writeFileMap opts ch (m, allYears) = do-  _ <- writeFiles' $ (addPreamble . toIncludeFiles' Map.empty Map.empty) allYears-  writeFiles . (toIncludeFiles opts ch) $ m- writeIncludesFromInventory :: (HasBaseDir o, HasVerbosity o) => o -> TChan LogMessage -> AbsDir -> [AbsFile] -> [AbsFile] -> IO [AbsFile] writeIncludesFromInventory opts ch stopAt inventory journals = do-  let base = baseDir opts-      importRoot = base Path.</> [Path.reldir|import|]+  (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) $@@ -97,41 +115,36 @@       years = yearsIncludeMap (Map.keys includes)       allAtStop = Path.toFilePath stopAt </> allYearsFileName       indexes = Map.insertWith (++) allAtStop [] years-      current = Map.union includes indexes-  -- Empty obsolete generated indexes as well as removing their parent links:-  -- users can open an account or year ledger directly.-  let old = managedIncludeFiles importRoot (filter (Path.isProperPrefixOf stopAt) inventory)-  mapM_ (clearObsolete current) old-  _ <- writeFileMap opts ch (includes, indexes)-  return [base Path.</> f | f <- Map.keys groups, Path.parent f == boundary]+      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-    clearObsolete current file = unless (Map.member (Path.toFilePath file) current) $ do-      contents <- T.readFile (Path.toFilePath file)-      if includePreamble `T.isPrefixOf` contents-        then do-          _ <- writeFiles' (Map.singleton (Path.toFilePath file) (includePreamble <> "\n"))-          return ()-        else return ()+    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 = do-  let directives = directivesFile opts-  directivesExists <- PathIO.doesFileExist directives-  let preMap = if directivesExists then Map.singleton (turtleBaseDir opts </> allYearsFileName) [Path.toFilePath directives] else Map.empty-  let allTop = Map.singleton (turtleBaseDir opts </> allYearsFileName) ["import" </> allYearsFileName]-  writeFiles' $ (addPreamble . toIncludeFiles' preMap Map.empty) allTop--extraIncludes :: (HasBaseDir o, HasVerbosity o) => o -> TChan LogMessage -> [FilePath] -> [T.Text] -> [FilePath] -> [FilePath] -> IO TurtleFileBundle-extraIncludes opts ch = extraIncludes' opts ch Map.empty+writeToplevelAllYearsInclude opts = toplevelAllYearsInclude (baseDir opts) >>= writeFiles' -extraIncludes' :: (HasBaseDir o, HasVerbosity o) => o -> TChan LogMessage -> TurtleFileBundle -> [FilePath] -> [T.Text] -> [FilePath] -> [FilePath] -> IO TurtleFileBundle-extraIncludes' _ _ acc [] _ _ _ = return acc-extraIncludes' opts ch acc (file : files) extraSuffixes manualFiles prices = do-  extra <- extraIncludesForFile opts ch file extraSuffixes manualFiles prices-  extraIncludes' opts ch (Map.unionWith (++) acc extra) files extraSuffixes manualFiles prices+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 file extraSuffixes manualFiles prices = do+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@@ -141,14 +154,12 @@   let priceFiles = map ((((FilePath.takeDirectory file </> "..") </> "prices") </> dirprefix) </>) prices   let extraFiles = suffixFiles ++ suffixDirFiles ++ priceFiles   filtered <- filterPaths doesFileExist extraFiles-  let logMsg =-        "Looking for possible extra include files for '" <> T.pack (relativeToBase opts file)+  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 (relativeFilesAsText opts extraFiles))+          <> T.pack (show (map (T.pack . relative) extraFiles))           <> ". Found " <> T.pack (show (length filtered)) <> ": "-          <> T.pack (show (relativeFilesAsText opts filtered))-  logVerbose opts ch logMsg+          <> T.pack (show (map (T.pack . relative) filtered))+  logMessage logMsg   return $ Map.fromList [(file, filtered)]--relativeFilesAsText :: (HasBaseDir o) => o -> [FilePath] -> [T.Text]-relativeFilesAsText opts = map (T.pack . relativeToBase opts)
src/Hledger/Flow/Import/Inventory.hs view
@@ -8,9 +8,11 @@     inImportScope,     generatedOutput,     expectedOutputs,+    classifyGeneratedOutputs,     conflictingOutputs,     canonicalFile,     manualIncludeFiles,+    isManualJournal,     managedIncludeFiles,     moveToTrash,   )@@ -73,6 +75,19 @@     return (key, [source])   return (Map.fromListWith (++) canonical) +-- | 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)+      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"])+ canonicalFile :: AbsFile -> IO AbsFile canonicalFile file = Directory.canonicalizePath (toFilePath file) >>= parseAbsFile @@ -111,17 +126,20 @@ -- 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]-manualIncludeFiles root files = Set.toAscList . Set.fromList <$> mapM includePath (filter supported files)+manualIncludeFiles root files = Set.toAscList . Set.fromList <$> mapM includePath (filter (isManualJournal root) files)   where-    supported file = case reverse (relativeParts root file) of-      name : year : "_manual_" : ancestors ->-        length ancestors <= 3 && isYear year-          && name `elem` ["pre-import.journal", "post-import.journal"]-      _ -> False     includePath file = do       let year = FilePath.dropTrailingPathSeparator (toFilePath (dirname (parent file)))       name <- parseRelFile (year ++ "-include.journal")       return (parent (parent (parent file)) </> name)++-- | A @_manual_@ pre- or post-import journal that import includes.+isManualJournal :: AbsDir -> AbsFile -> Bool+isManualJournal root file = case reverse (relativeParts root file) of+  name : year : "_manual_" : ancestors ->+    length ancestors <= 3 && isYear year+      && name `elem` ["pre-import.journal", "post-import.journal"]+  _ -> False  managedIncludeFiles :: AbsDir -> [AbsFile] -> [AbsFile] managedIncludeFiles root = filter supported
+ src/Hledger/Flow/Init.hs view
@@ -0,0 +1,70 @@+{-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE QuasiQuotes #-}++-- | Start a new ledger.+module Hledger.Flow.Init+  ( InitOutcome (..),+    initLedger,+    runInit,+  )+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 Hledger.Flow.PathHelpers (AbsDir)+import Hledger.Flow.Status.Render (printNext, tildePath)+import Hledger.Flow.Types (BaseDir)+import Path+import Path.IO (createDirIfMissing, doesDirExist, getHomeDir)+import qualified System.FilePath as FilePath++data InitOutcome+  = -- | The new ledger, and an unrelated ledger above it, if any.+    Created BaseDir (Maybe BaseDir)+  | -- | The folder already has @import/@.+    AlreadyLedger BaseDir+  | -- | The folder is inside this ledger's @import/@ tree.+    InsideLedger BaseDir+  deriving (Eq, Show)++-- | Create @DIR/import/@ unless that would nest inside another ledger.+initLedger :: AbsDir -> IO InitOutcome+initLedger dir = do+  hasImport <- doesDirExist (dir </> [reldir|import|])+  if hasImport+    then return (AlreadyLedger dir)+    else do+      above <- findBaseDir dir+      case above of+        Just b | let imp = b </> [reldir|import|], imp == dir || imp `isProperPrefixOf` dir -> return (InsideLedger b)+        _ -> do+          createDirIfMissing True (dir </> [reldir|import|])+          return (Created dir above)++-- | 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+  outcome <- initLedger dir+  case outcome of+    AlreadyLedger b ->+      ioError . userError . T.unpack $+        shown b <> " is already a hledger-flow ledger. Run `hledger-flow status` there to see where it stands."+    InsideLedger b ->+      ioError . userError . T.unpack $+        "That folder is inside the ledger at " <> shown b <> ". Start a new ledger outside its import/ folder."+    Created base parentLedger -> do+      T.putStrLn ("Started a ledger in " <> shown base)+      mapM_ (\p -> T.putStrLn ("Note: " <> shown p <> " also has an import/ folder. Commands run inside " <> shown base <> " use the new ledger.")) parentLedger+      when (base /= cwd) $ do+        let changeDir = case nativeShell of+              Posix -> "cd "+              PowerShell -> "Set-Location -LiteralPath "+        T.putStrLn ("Change to it first: " <> changeDir <> shellArgument nativeShell (absolute base))+      printNext hledgerPath base
src/Hledger/Flow/Internals.hs view
@@ -36,6 +36,10 @@         ++ $(gitHash)     ) +-- | The git commit this binary was built from, abbreviated; @UNKNOWN@ outside git.+gitCommit :: String+gitCommit = take 7 $(gitHash)+ systemInfo :: IO SystemInfo systemInfo = do   processors <- getNumProcessors
src/Hledger/Flow/Logging.hs view
@@ -9,6 +9,7 @@ import qualified Data.Text.IO as T import Data.Time.LocalTime (getZonedTime) import qualified GHC.IO.Handle.FD as H+import System.IO (hFlush) import Hledger.Flow.Types  dummyLogger :: TChan LogMessage -> T.Text -> IO ()@@ -51,6 +52,8 @@   case logMsg of     StdOut msg -> do       T.hPutStr H.stdout msg+      -- Keep stdout and stderr lines in the order they were logged.+      hFlush H.stdout       consoleChannelLoop ch     StdErr msg -> do       T.hPutStr H.stderr msg
src/Hledger/Flow/PathHelpers.hs view
@@ -24,12 +24,13 @@  instance Show PathException where   show (MissingBaseDir d) =-    "Unable to find an import directory at "-      ++ show d-      ++ " (or in any of its parent directories).\n\n"-      ++ "Have a look at the documentation for more information:\n"+    "No hledger-flow ledger here or in any parent folder (looked from "+      ++ Path.toFilePath d+      ++ ").\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) = "Expected a directory but got this instead: " ++ d+  show (InvalidTurtleDir d) = "Not a folder: " ++ d  instance Exception PathException 
+ src/Hledger/Flow/ReleaseNotice.hs view
@@ -0,0 +1,128 @@+{-# LANGUAGE OverloadedStrings #-}++module Hledger.Flow.ReleaseNotice+  ( withReleaseNotice, startCheck, finishCommand, checkRelease+  ) where++import Control.Concurrent.Async (async, poll)+import Control.Exception (catch, evaluate, throwIO, try)+import Control.Monad (guard, join)+import Data.Aeson (decode, decodeFileStrict, withObject, (.:), (.:?), (.!=))+import Data.Aeson.Types (parseMaybe)+import qualified Data.ByteString.Lazy as BL+import Data.Char (isDigit)+import Data.Maybe (fromMaybe, listToMaybe)+import qualified Data.Text as T+import qualified Data.Text.Encoding as T+import qualified Data.Text.IO as T+import Data.Version (Version, parseVersion, showVersion)+import Network.HTTP.Client (HttpException, checkResponse, httpLbs, parseRequest, requestHeaders, responseBody, throwErrorStatusCodes)+import Network.HTTP.Client.TLS (getGlobalManager)+import Paths_hledger_flow (version)+import System.Directory (canonicalizePath)+import System.Environment (getExecutablePath, lookupEnv)+import System.Exit (ExitCode)+import System.FilePath ((</>), takeDirectory, takeFileName)+import System.IO (hIsTerminalDevice, stderr, stdout)+import System.IO.Error (catchIOError)+import System.Timeout (timeout)+import Text.ParserCombinators.ReadP (eof, readP_to_S)++withReleaseNotice :: IO () -> IO ()+withReleaseNotice command = do+  finish <- setup `catchIOError` const (pure (pure ()))+  finishCommand finish command+  where+    setup = do+      disabled <- lookupEnv "HLEDGER_FLOW_NO_UPDATE_CHECK"+      outTTY <- hIsTerminalDevice stdout+      errTTY <- hIsTerminalDevice stderr+      if disabled /= Just "1" && outTTY && errTTY+        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 ())+startCheck check = do+  worker <- async $ do+    notice <- check+    traverse evaluate notice+  pure $ do+    result <- poll worker+    case result of+      Just (Right (Just notice)) -> T.hPutStrLn stderr notice+      _ -> pure ()++-- 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 ()+finishCommand finish command = do+  outcome <- try command :: IO (Either ExitCode ())+  finish `catchIOError` const (pure ())+  either throwIO pure outcome++fetch :: String -> IO (Maybe BL.ByteString)+fetch url = request `catch` ignoreHttpError+  where+    request = do+      manager <- getGlobalManager+      req <- parseRequest url+      let githubRequest = req { requestHeaders = [("User-Agent", "hledger-flow")]+                              , checkResponse = throwErrorStatusCodes }+      response <- httpLbs githubRequest manager+      pure (Just (responseBody response))+    ignoreHttpError :: HttpException -> IO (Maybe BL.ByteString)+    ignoreHttpError _ = pure Nothing++checkRelease :: (String -> IO (Maybe BL.ByteString)) -> IO FilePath -> IO (Maybe T.Text)+checkRelease get executable = do+  result <- timeout 3000000 (get "https://api.github.com/repos/apauley/hledger-flow/releases/latest")+  case join result >>= releaseVersion of+    Just latest | latest > version -> do+      confirmed <- timeout 1000000 (homebrewOffers latest `catchIOError` const (pure False))+      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+  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 <> "\""+        pure $ fromMaybe False $ do+          body <- formula+          contents <- either (const Nothing) Just (T.decodeUtf8' (BL.toStrict body))+          pure $ tagLine `elem` map T.strip (T.lines contents)+      else pure False++releaseVersion :: BL.ByteString -> Maybe Version+releaseVersion bytes = do+  value <- decode bytes+  tag <- parseMaybe (withObject "release" $ \o -> do+    draft <- o .:? "draft" .!= False+    prerelease <- o .:? "prerelease" .!= False+    guard (not draft && not prerelease)+    o .: "tag_name") value+  number <- T.stripPrefix "v" tag+  guard (T.all (\c -> isDigit c || c == '.') number)+  listToMaybe [v | (v, "") <- readP_to_S (parseVersion <* eof) (T.unpack number)]++ownsHomebrewBinary :: FilePath -> IO Bool+ownsHomebrewBinary path+  | map takeFileName [path, bin, rack, takeDirectory rack] /=+      ["hledger-flow", "bin", "hledger-flow", "Cellar"] = pure False+  | otherwise = do+      receipt <- decodeFileStrict (keg </> "INSTALL_RECEIPT.json")+      let tap = receipt >>= parseMaybe (withObject "receipt" $ \o ->+                  o .: "source" >>= withObject "source" (.: "tap"))+      pure $ fmap T.toCaseFold tap == Just "ferntrail/tap"+  where+    bin = takeDirectory path+    keg = takeDirectory bin+    rack = takeDirectory keg
src/Hledger/Flow/Reports.hs view
@@ -11,20 +11,20 @@ import Data.Char (toLower) import Data.Either import qualified Data.List as List-import Data.Maybe import qualified Data.Text as T import qualified Data.Text.IO as T import Hledger.Flow.DateTime (timeAction)+import Hledger.Flow.Environment (renderSettings, reportSettings) import Hledger.Flow.BaseDir (relativeToBase) import Hledger.Flow.Common import Hledger.Flow.Logging import Hledger.Flow.PathHelpers (AbsDir, AbsFile, RelDir, RelFile) import Hledger.Flow.RuntimeOptions+import Hledger.Flow.Status (plural) import qualified Hledger.Flow.Types as FlowTypes import Path ((</>), parent, reldir, relfile, parseRelDir, toFilePath) import qualified Path.IO as PathIO import qualified System.Directory as Directory-import System.Environment (lookupEnv) import Prelude hiding (putStrLn, readFile, writeFile)  data ReportParams = ReportParams@@ -38,24 +38,24 @@  generateReports :: RuntimeOptions -> IO () generateReports opts = withConsoleLogging $ \ch -> do-  when (showOptions opts) $ channelOutLn ch (T.pack (show opts))+  when (showOptions opts) $ channelOutLn ch (T.unlines (renderSettings id (reportSettings opts)))   (reports, diff) <- timeAction $ generateReports' opts ch-  let failedAttempts = lefts reports-  let failedText = if List.null failedAttempts then "" else "(and attempted to write " <> T.pack (show (length failedAttempts)) <> " more) "-  channelOutLn ch $ "Generated " <> T.pack (show (length (rights reports))) <> " reports " <> failedText <> "in " <> T.pack (show diff)+  let skipped = length (lefts reports)+      skippedText = if skipped == 0 then "" else " Skipped " <> plural skipped "empty report" <> "."+  channelOutLn ch $ "Generated " <> plural (length (rights reports)) "report" <> " in reports/." <> skippedText+  channelOutLn ch "Each report starts with the hledger command that made it. Copy that line to tailor the report."+  logVerbose opts ch ("Reports took " <> T.pack (show diff))  generateReports' :: RuntimeOptions -> TChan FlowTypes.LogMessage -> IO [Either AbsFile AbsFile] generateReports' opts ch = do-  let wipMsg =-        "These reports can be used as a starting point for more tailored reports.\n"-          <> "The first line of each report contains the command used - change the parameters and use it in your own reports.\n"-  channelOutLn ch wipMsg   owners <- listOwners opts-  ledgerEnvValue <- lookupEnv "LEDGER_FILE"-  let hledgerJournal = fromMaybe (toFilePath (baseDir opts </> [relfile|all-years.journal|])) ledgerEnvValue+  configured <- configuredLedgerFile+  let hledgerJournal = maybe (toFilePath (baseDir opts </> [relfile|all-years.journal|])) snd configured   hledgerJournalExists <- Directory.doesFileExist hledgerJournal-  unless hledgerJournalExists $ ioError $ userError $-    "Unable to find journal file: " ++ hledgerJournal ++ "\nIs your LEDGER_FILE environment variable set correctly?"+  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."   let journalWithYears = baseDir opts </> [relfile|import/all-years.journal|]   let aggregateReportDir         | any ((== toFilePath [reldir|all|]) . map toLower . toFilePath) owners = baseDir opts </> [reldir|reports/_all_|]@@ -124,14 +124,14 @@   let reportDisplayArgs = confArgs ++ fileArgs relativeJournals ++ periodArgs ++ args   let hledger = T.pack (toFilePath (FlowTypes.hlPath (hledgerInfo opts)))   let cmdLabel = "hledger " <> showCmdArgs reportDisplayArgs-  ((exitCode, stdOut, _), _) <- timeAndExitOnErr opts ch cmdLabel dummyLogger channelErr (hledger, reportArgs, Nothing)+  ((_, stdOut, _), _) <- timeAndExitOnErr opts ch cmdLabel dummyLogger channelErr (hledger, reportArgs, Nothing)   if (successCheck stdOut)     then do       T.writeFile (toFilePath outputFile) (cmdLabel <> "\n\n" <> stdOut)       logVerbose opts ch $ "Wrote " <> T.pack relativeOutputFile       return $ Right outputFile     else do-      channelErrLn ch $ "Did not write '" <> T.pack relativeOutputFile <> "' (" <> cmdLabel <> ") " <> T.pack (show exitCode)+      logVerbose opts ch $ "Skipped empty report " <> T.pack relativeOutputFile       exists <- PathIO.doesFileExist outputFile       when exists (PathIO.removeFile outputFile)       return $ Left outputFile
+ src/Hledger/Flow/RulesTemplate.hs view
@@ -0,0 +1,154 @@+{-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE QuasiQuotes #-}++-- | A starter hledger rules file built from a statement's header row.+module Hledger.Flow.RulesTemplate+  ( splitHeader,+    looksLikeHeader,+    templateFields,+    rulesTemplate,+    rulesTemplateFor,+  )+where++import Data.Char (isAlphaNum, isDigit, toLower)+import qualified Data.List as List+import Data.Maybe (fromMaybe)+import qualified Data.Text as T+import Hledger.Flow.BaseDir (relativeToBase')+import Hledger.Flow.DocHelpers (docURL)+import Hledger.Flow.Import.Inventory (findImportFiles, stageFiles)+import Hledger.Flow.Status.Gather (accountName, cachedStatement)+import Hledger.Flow.Types (BaseDir)+import Path+import qualified System.FilePath as FilePath+import System.IO (IOMode (ReadMode), hIsEOF, hSetEncoding, mkTextEncoding, withFile)+import qualified Data.Text.IO as T++-- | The separator and the column names of a header line. Strips a UTF-8 BOM,+-- surrounding quotes and spaces.+-- ponytail: a quoted name containing the separator is split; use a CSV parser if that bites.+splitHeader :: T.Text -> (Char, [T.Text])+splitHeader line = (sep, map clean (T.splitOn (T.singleton sep) l))+  where+    l = T.dropWhileEnd (== '\r') (T.dropWhile (== '\xFEFF') line)+    count c = T.count (T.singleton c) l+    sep = List.foldl' (\best c -> if count c > count best then c else best) ',' [';', '\t']+    clean = T.strip . unquote . T.strip+    unquote t+      | T.length t >= 2 && T.head t == '"' && T.last t == '"' = T.init (T.tail t)+      | otherwise = t++-- | False when any column looks like a date or an amount.+looksLikeHeader :: [T.Text] -> Bool+looksLikeHeader = not . any dataish+  where+    dataish f = not (T.null f) && T.all (`elem` ("0123456789-/.:,+$€£ " :: String)) f++-- | hledger field names for the columns: obvious date, description and amount+-- columns are named for hledger; the rest keep a normalised version of their name.+templateFields :: [T.Text] -> [T.Text]+templateFields names = dedupe (zipWith pick [0 ..] normalised)+  where+    normalised = zipWith normalise [1 :: Int ..] names+    normalise i n =+      let t = T.intercalate "_" (filter (not . T.null) (T.splitOn "_" (T.map (\c -> if isAlphaNum c then toLower c else '_') n)))+       in if T.null t then "field" <> T.pack (show i) else t+    indexed = zip [0 :: Int ..] normalised+    dateIx = fst <$> List.find (("date" `T.isInfixOf`) . snd) indexed+    descIx = fst <$> List.find (\(i, n) -> Just i /= dateIx && any (`T.isInfixOf` n) ["desc", "payee", "narrat", "detail"]) indexed+    amountIx = case [i | (i, n) <- indexed, "amount" `T.isInfixOf` n, Just i /= dateIx, Just i /= descIx] of+      [i] -> Just i+      _ -> fst <$> List.find ((== "amount") . snd) indexed+    pick i n+      | Just i == dateIx = "date"+      | Just i == descIx = "description"+      | Just i == amountIx = "amount"+      | isSpecialField n = "bank_" <> n+      | otherwise = n+    dedupe = go []+      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++-- | Names hledger gives a meaning of its own, such as balance (assertions) or status.+isSpecialField :: T.Text -> Bool+isSpecialField n =+  n `elem` ["status", "code", "comment", "balance", "currency", "date2", "account", "amount", "date", "description"]+    || (T.dropWhileEnd isDigit n `elem` ["account", "amount", "balance", "comment", "currency"] && T.any isDigit n)++notCsv :: FilePath -> T.Text+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"++-- | 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")+  | otherwise =+      Right . T.unlines $+        [ "# Starter rules for " <> bank <> "/" <> account <> ", made by hledger-flow from the header row of",+          "# " <> T.pack path <> ". Check every line, then run: hledger-flow import",+          "# hledger's CSV rules reference: https://hledger.org/hledger.html#csv",+          "",+          "skip 1"+        ]+          ++ ["separator " <> (if sep == '\t' then "TAB" else T.singleton sep) | sep /= ',']+          ++ [ "fields " <> T.intercalate ", " fields,+               ""+             ]+          ++ [ "# Columns prefixed with bank_ would otherwise mean something to hledger.\n"+                 <> "# For example, rename bank_balance to balance to check balance assertions.\n"+               | any (\f -> maybe False isSpecialField (T.stripPrefix "bank_" f)) fields+             ]+          ++ [+               "# Make the date format match your statement, e.g. %d/%m/%Y",+               "# date-format %Y-%m-%d",+               "",+               "# Separate debit and credit columns? Name them amount-out and amount-in",+               "# in the fields line instead of amount.",+               "",+               "account1 assets:" <> bank <> ":" <> account,+               "account2 expenses:unknown"+             ]+  where+    line = T.takeWhile (/= '\n') contents+    (sep, names) = splitHeader line+    fields = templateFields names++-- | The starter rules file for an account (@owner/bank/account@), from its newest statement.+rulesTemplateFor :: BaseDir -> T.Text -> IO (Either T.Text T.Text)+rulesTemplateFor base requested = do+  let account = T.dropWhileEnd (== '/') (maybe requested id (T.stripPrefix "import/" requested))+      root = base </> [reldir|import|]+  files <- findImportFiles root+  let inputs = stageFiles root [reldir|1-in|] files+      statements = filter ((== account) . accountName root) inputs+      known = List.nub (map (accountName root) inputs)+  case reverse statements of+    [] ->+      return . Left $+        "No statements found for " <> account <> ". Accounts with statements: "+          <> (if null known then "none" else T.intercalate ", " known) <> "."+    newest : _ -> do+      source <- fromMaybe newest <$> cachedStatement newest+      let path = relativeToBase' (toFilePath base) (toFilePath source)+          isCsv = map toLower (FilePath.takeExtension path) `elem` [".csv", ".tsv", ".ssv"]+          (bank, acct) = case T.splitOn "/" account of+            [_, b, a] -> (b, a)+            _ -> (account, account)+      header <- if isCsv then firstLine (toFilePath source) else return ""+      return (rulesTemplate bank acct path header)++-- | The first line of a file, tolerating encodings other than UTF-8.+firstLine :: FilePath -> IO T.Text+firstLine path = withFile path ReadMode $ \h -> do+  hSetEncoding h =<< mkTextEncoding "UTF-8//ROUNDTRIP"+  eof <- hIsEOF h+  if eof then return "" else T.hGetLine h
+ src/Hledger/Flow/Status.hs view
@@ -0,0 +1,173 @@+{-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE QuasiQuotes #-}++-- | What a ledger contains, and the one thing to do next.+module Hledger.Flow.Status+  ( Situation (..),+    LedgerFile (..),+    AccountStatus (..),+    Misplaced (..),+    LedgerStatus (..),+    NextStep (..),+    statementExtensions,+    suggestPlacement,+    misplacedFiles,+    nextStep,+    hledgerVersionNumber,+    plural,+  )+where++import Data.Char (isDigit, toLower)+import Data.Maybe (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.Import.Inventory (stageFiles)+import Hledger.Flow.PathHelpers (AbsDir, AbsFile)+import Hledger.Flow.Types (BaseDir, HledgerInfo)+import Path (reldir, relfile, stripProperPrefix, toFilePath, (</>))+import qualified System.FilePath as FilePath++data Situation = InLedger BaseDir | EmptyDir | Elsewhere deriving (Eq, Show)++data LedgerFile = LedgerFile {lfValue :: FilePath, lfExists :: Bool} deriving (Eq, Show)++data AccountStatus = AccountStatus+  { asAccount :: T.Text,+    asStatements :: Int,+    asYears :: [Integer],+    asRulesFile :: Maybe FilePath,+    asRulesCandidates :: [FilePath],+    asWithoutRules :: Int,+    asPreprocess :: Bool,+    asConstruct :: Bool,+    asJournalsMissing :: Int,+    asJournalsOutdated :: Int+  }+  deriving (Eq, Show)++-- | Both paths are relative to the base directory and start with @import@.+data Misplaced = Misplaced {mpPath :: FilePath, mpSuggestion :: FilePath} deriving (Eq, Show)++data LedgerStatus = LedgerStatus+  { lsBaseDir :: BaseDir,+    lsHledger :: Maybe HledgerInfo,+    lsLedgerFile :: Maybe LedgerFile,+    lsAccounts :: [AccountStatus],+    lsMisplaced :: [Misplaced],+    lsManualJournals :: Int,+    lsAllYearsExists :: Bool,+    lsNeedsReconciliation :: Bool+  }+  deriving (Show)++data NextStep = NextStep+  { nsSummary :: T.Text,+    nsCommand :: Maybe T.Text,+    nsDetail :: [T.Text],+    nsDocs :: Maybe T.Text+  }+  deriving (Eq, Show)++statementExtensions :: [String]+statementExtensions = ["csv", "tsv", "ssv", "ofx", "qfx", "qif", "xls", "xlsx"]++isYear :: String -> Bool+isYear s = length s == 4 && all isDigit s++-- | Where a file found at these path components (below @import/@) belongs.+suggestPlacement :: [FilePath] -> FilePath+suggestPlacement parts = FilePath.joinPath (["import"] ++ identity ++ ["1-in", year, file])+  where+    dirs = if null parts then [] else init parts+    file = if null parts then "" else last parts+    year = case filter isYear dirs of+      y : _ -> y+      [] -> "<year>"+    stages = ["1-in", "2-preprocessed", "3-journal"]+    named = take 3 (takeWhile (\d -> d `notElem` stages && not (isYear d)) dirs)+    identity = named ++ drop (length named) ["<owner>", "<bank>", "<account>"]++-- | Statement-like files under the import root that import will never read.+misplacedFiles :: AbsDir -> [AbsFile] -> [Misplaced]+misplacedFiles root files = mapMaybe misplaced (filter candidate files)+  where+    staged = Set.fromList (stageFiles root [reldir|1-in|] files ++ cachedCSVs)+    cachedCSVs = filter ((== ".csv") . FilePath.takeExtension . toFilePath) (stageFiles root [reldir|2-preprocessed|] files)+    candidate f = extension f `elem` statementExtensions && Set.notMember f staged+    extension = map toLower . drop 1 . FilePath.takeExtension . toFilePath+    misplaced f = do+      rel <- stripProperPrefix root f+      let parts = FilePath.splitDirectories (toFilePath rel)+      return (Misplaced (FilePath.joinPath ("import" : parts)) (suggestPlacement parts))++plural :: Int -> T.Text -> T.Text+plural 1 noun = "1 " <> noun+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+  | 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) =+      NextStep+        "Put your first statement in import/<owner>/<bank>/<account>/1-in/<year>/"+        Nothing+        [ "For example: import/me/mybank/checking/1-in/" <> T.pack (show year) <> "/statement.csv",+          "Then check with: " <> command "status"+        ]+        (Just (docURL "input-files"))+  | 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"))+  | a : _ <- filter needsRules (lsAccounts s) =+      NextStep+        ("Create " <> T.pack (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"))+  | toImport > 0 =+      NextStep ("Import " <> plural toImport "new or changed statement") (Just importCommand) [] Nothing+  | not (lsAllYearsExists s) =+      NextStep "Run import to create all-years.journal" (Just importCommand) [] Nothing+  | lsNeedsReconciliation s =+      NextStep "Run import to reconcile generated files and journal includes" (Just importCommand) [] Nothing+  | maybe True (not . lfExists) (lsLedgerFile s) =+      NextStep+        "Point LEDGER_FILE at this ledger so hledger finds it"+        (Just exportLine)+        [ "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")+  | 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))))+    importCommand = command "import"+    statements = sum (map asStatements (lsAccounts s))+    toImport = sum [asJournalsMissing a + asJournalsOutdated a | a <- lsAccounts s]+    needsRules a = asWithoutRules a > 0 && not (asConstruct a)+    firstCandidate a = case asRulesCandidates a of+      c : _ | cwd /= lsBaseDir s -> toFilePath (lsBaseDir s) FilePath.</> c+            | otherwise -> c+      [] -> "a rules file"+    allYears = T.pack (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+  [] -> ""
+ src/Hledger/Flow/Status/Gather.hs view
@@ -0,0 +1,147 @@+{-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE QuasiQuotes #-}++-- | Collect a ledger's status from disk. Read-only and silent.+module Hledger.Flow.Status.Gather+  ( classifyDir,+    gatherStatus,+    accountName,+    cachedStatement,+    hookExists,+  )+where++import Control.Monad (forM)+import qualified Data.List as List+import qualified Data.Map.Strict as Map+import Data.Maybe (catMaybes, fromMaybe, isJust, isNothing)+import qualified Data.Text as T+import Hledger.Flow.BaseDir (findBaseDir, relativeToBase')+import Hledger.Flow.Common (configuredLedgerFile, firstExistingFile)+import Hledger.Flow.Import.ImportHelpers (defaultRulesCandidates, extractImportDirs, rulesFileCandidates)+import Hledger.Flow.Import.ImportHelpersTurtle (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)+import Hledger.Flow.Status+import Hledger.Flow.Types (BaseDir, HledgerInfo)+import Path+import qualified Path.IO as PathIO+import qualified System.Directory as Directory+import qualified System.FilePath as FilePath+import qualified System.Info as Sys+import System.IO.Error (tryIOError)++-- | Is this folder inside a ledger, empty, or something else?+classifyDir :: AbsDir -> IO Situation+classifyDir dir = do+  found <- findBaseDir dir+  case found of+    Just base -> return (InLedger base)+    Nothing -> do+      (dirs, files) <- PathIO.listDirRel dir+      let names = map (FilePath.dropTrailingPathSeparator . toFilePath) dirs ++ map toFilePath files+      return (if all ("." `List.isPrefixOf`) names then EmptyDir else Elsewhere)++gatherStatus :: Maybe HledgerInfo -> BaseDir -> IO LedgerStatus+gatherStatus hledger base = do+  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])+  accounts <- forM groups (uncurry (accountStatus base))+  configured <- configuredLedgerFile+  ledgerFile <- forM configured $ \(value, path) -> LedgerFile value <$> Directory.doesFileExist path+  allYears <- PathIO.doesFileExist (base </> [relfile|all-years.journal|])+  (orphans, journals) <- classifyGeneratedOutputs root files statements+  -- A newly initialised, empty ledger has no indexes to reconcile yet.+  reconciliation <- if allYears && null orphans+    then includesNeedUpdate base root files journals+    else return (not (null orphans) || not (null (managedIncludeFiles root files)))+  return+    LedgerStatus+      { lsBaseDir = base,+        lsHledger = hledger,+        lsLedgerFile = ledgerFile,+        lsAccounts = accounts,+        lsMisplaced = List.sortOn mpPath (misplacedFiles root files),+        lsManualJournals = length (filter (isManualJournal root) files),+        lsAllYearsExists = allYears,+        lsNeedsReconciliation = reconciliation+      }++accountName :: AbsDir -> AbsFile -> T.Text+accountName root file = case stripProperPrefix root file of+  Just rel -> T.intercalate "/" (map T.pack (take 3 (FilePath.splitDirectories (toFilePath rel))))+  Nothing -> T.pack (toFilePath file)++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)+    [] -> ioError (userError ("No statements for " ++ T.unpack name))+  let relative = relativeToBase' (toFilePath base) . toFilePath+  defaults <- defaultRulesCandidates dirs+  rulesFile <- firstExistingFile defaults+  preprocess <- hookFile (accountDir dirs) [relfile|preprocess|]+  construct <- hookFile (accountDir dirs) [relfile|construct|]+  directives <- firstExistingFile [base </> [relfile|directives.journal|]]+  results <- 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)+    journal <- generatedOutput [reldir|3-journal|] ".journal" s+    state <- journalState inputs journal+    return (rules, state)+  let (resolved, journals) = unzip results+  return+    AccountStatus+      { asAccount = name,+        asStatements = length statements,+        asYears = List.nub (List.sort (map yearOf statements)),+        asRulesFile = relative <$> rulesFile,+        asRulesCandidates = map relative defaults,+        asWithoutRules = length (filter isNothing resolved),+        asPreprocess = isJust preprocess,+        asConstruct = isJust construct,+        asJournalsMissing = length (filter (== Missing) journals),+        asJournalsOutdated = length (filter (== Outdated) journals)+      }+  where+    yearOf s = read (FilePath.dropTrailingPathSeparator (toFilePath (dirname (parent s))))++data JournalState = Current | Outdated | Missing deriving (Eq)++journalState :: [AbsFile] -> AbsFile -> IO JournalState+journalState inputs 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+      return (if either (const True) id newer then Outdated else Current)+    else return Missing++-- | Import reads this statement's cached CSV when present, even without a hook.+cachedStatement :: AbsFile -> IO (Maybe AbsFile)+cachedStatement statement = do+  csv <- generatedOutput [reldir|2-preprocessed|] ".csv" statement+  firstExistingFile [csv]++-- | Same hook names import looks for.+hookExists :: AbsDir -> RelFile -> IO Bool+hookExists dir hook = isJust <$> hookFile dir hook++hookFile :: AbsDir -> RelFile -> IO (Maybe AbsFile)+hookFile dir hook = do+  let base = dir </> hook+  alternatives <-+    if Sys.os == "mingw32"+      then mapM (`addExtension` base) [".ps1", ".exe"]+      else return []+  firstExistingFile (base : alternatives)
+ src/Hledger/Flow/Status/Render.hs view
@@ -0,0 +1,181 @@+{-# LANGUAGE OverloadedStrings #-}++-- | Status as text for people and JSON for scripts and agents.+module Hledger.Flow.Status.Render+  ( useColor,+    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.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 qualified Data.Text as T+import Hledger.Flow.PathHelpers (AbsDir)+import Hledger.Flow.Status+import Hledger.Flow.Types (HledgerInfo (..))+import Path (toFilePath)+import System.Console.ANSI (Color (..), ColorIntensity (..), ConsoleIntensity (..), ConsoleLayer (..), SGR (..), hSupportsANSIColor, setSGRCode)+import System.Environment (lookupEnv)+import qualified System.FilePath as FilePath+import System.IO (Handle, stdout)++-- | Colour only on a terminal, and only when NO_COLOR is unset or empty.+useColor :: Handle -> IO Bool+useColor h = do+  noColor <- lookupEnv "NO_COLOR"+  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])++green, red, yellow, bold :: Bool -> T.Text -> T.Text+green c = paint c [SetColor Foreground Vivid Green]+red c = paint c [SetColor Foreground Vivid Red]+yellow c = paint c [SetColor Foreground Vivid Yellow]+bold c = paint c [SetConsoleIntensity BoldIntensity]++baseText :: LedgerStatus -> FilePath+baseText = FilePath.dropTrailingPathSeparator . toFilePath . lsBaseDir++renderStatus :: Bool -> Maybe AbsDir -> LedgerStatus -> NextStep -> T.Text+renderStatus color home s next =+  T.unlines (header : accountBlock ++ misplacedBlock) <> renderNext color next+  where+    header = bold color "Ledger: " <> T.pack (tildePath home (baseText s)) <> "   (" <> hledgerText <> ")"+    hledgerText = case lsHledger s of+      Just hl -> "hledger " <> hledgerVersionNumber (hlVersion hl) <> " found"+      Nothing -> red color "hledger not found"+    rows = [(asAccount a, counts a, state a) | a <- lsAccounts s]+    width f = maximum (0 : map (T.length . f) rows)+    accountBlock+      | null rows = []+      | otherwise =+          "" : ["  " <> T.justifyLeft (width fst3) ' ' name <> "   " <> T.justifyLeft (width snd3) ' ' n <> "   " <> st | (name, n, st) <- rows]+    fst3 (x, _, _) = x+    snd3 (_, x, _) = x+    counts a = plural (asStatements a) "statement" <> years (asYears a)+    years [] = ""+    years [y] = " (" <> tshow y <> ")"+    years (y : ys) = " (" <> tshow y <> "–" <> tshow (last ys) <> ")"+    state a+      | asWithoutRules a > 0 && not (asConstruct a) = red color "✗ no rules file"+      | otherwise =+          let missing = asJournalsMissing a+              outdated = asJournalsOutdated a+              imported = asStatements a - missing+              parts =+                [plural imported "journal" | imported > 0]+                  ++ [tshow missing <> " not imported" | missing > 0]+                  ++ [tshow outdated <> " outdated" | outdated > 0]+              txt = T.intercalate ", " parts+           in if missing + outdated > 0 then yellow color ("• " <> txt) else green color ("✓ " <> txt)+    misplacedBlock = case length (lsMisplaced s) of+      0 -> []+      n -> ["", "  " <> yellow color ("⚠ " <> plural n "file" <> " not where hledger-flow looks")]++renderNext :: Bool -> NextStep -> T.Text+renderNext color next =+  T.unlines $+    ""+      : (bold color "Next:" <> " " <> nsSummary next)+      : map ("      " <>) (["Run: " <> c | Just c <- [nsCommand next]] ++ nsDetail next ++ ["Docs: " <> d | Just d <- [nsDocs next]])++tshow :: Show a => a -> T.Text+tshow = T.pack . show++statusJSON :: SystemInfo -> Maybe FilePath -> LedgerStatus -> NextStep -> Value+statusJSON sys conf s next =+  object+    [ "schemaVersion" .= (1 :: Int),+      "environment" .= environmentJSON sys conf,+      "baseDir" .= baseText s,+      "hledger" .= fmap hledgerJSON (lsHledger s),+      "ledgerFile" .= fmap (\lf -> object ["value" .= lfValue lf, "exists" .= lfExists lf]) (lsLedgerFile s),+      "accounts" .= map accountJSON (lsAccounts s),+      "misplaced" .= [object ["path" .= mpPath m, "suggestion" .= mpSuggestion m] | m <- lsMisplaced s],+      "manualJournals" .= lsManualJournals s,+      "allYearsJournalExists" .= lsAllYearsExists s,+      "next"+        .= object+          [ "summary" .= nsSummary next,+            "command" .= nsCommand next,+            "detail" .= nsDetail next,+            "docs" .= nsDocs next+          ]+    ]+  where+    hledgerJSON hl = object ["path" .= toFilePath (hlPath hl), "version" .= hledgerVersionNumber (hlVersion hl)]+    accountJSON a =+      object+        [ "account" .= asAccount a,+          "statements" .= asStatements a,+          "years" .= asYears a,+          "rulesFile" .= asRulesFile a,+          "rulesCandidates" .= asRulesCandidates a,+          "statementsWithoutRules" .= asWithoutRules a,+          "hooks" .= object ["preprocess" .= asPreprocess a, "construct" .= asConstruct a],+          "journalsMissing" .= asJournalsMissing a,+          "journalsOutdated" .= asJournalsOutdated a+        ]++nextStepHere :: LedgerStatus -> IO NextStep+nextStepHere status = do+  year <- currentYear+  cwd <- getCurrentDir+  return (nextStep year nativeShell 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 =+  T.unlines ("" : bold color "Environment:" : map ("  " <>) (renderSettings (red color) settings))+  where+    settings = environmentSettings sys (lsHledger s) conf (lsBaseDir s) ++ [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+  hledger <- findHledger hledgerPath+  status <- gatherStatus hledger base+  next <- nextStepHere status+  sys <- systemInfo+  if json+    then 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)++-- | 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+  color <- useColor stdout+  T.putStr (renderNext color next)
test/BaseDir/Integration.hs view
@@ -7,13 +7,13 @@ import Control.Exception (try) import qualified Data.Text as T import qualified Data.Text.IO as T-import Hledger.Flow.BaseDir (determineBaseDir, effectiveRunDir)+import Hledger.Flow.BaseDir (determineBaseDir, effectiveRunDir, findBaseDir) import Hledger.Flow.PathHelpers import Hledger.Flow.Types (BaseDir, RunDir) import Path import Path.IO import Test.HUnit-import TestHelpers (testRoot)+import TestHelpers (testRoot, withTestDir) import Prelude hiding (readFile, writeFile)  assertSubDirsForDetermineBaseDir :: AbsDir -> BaseDir -> [Path.Path b Dir] -> IO ()@@ -167,4 +167,14 @@     )  tests :: Test-tests = TestList [testDetermineBaseDir, testRunDirs, testEffectiveRunDir]+tests = TestList [testDetermineBaseDir, testRunDirs, testEffectiveRunDir, testFindBaseDir]++testFindBaseDir :: Test+testFindBaseDir = TestCase $ withTestDir $ \tmp -> do+  let base = tmp </> [reldir|ledger|]+      deep = base </> [reldir|import/me/bank/acct/1-in/2026|]+  createDirIfMissing True deep+  found <- findBaseDir deep+  assertEqual "finds the ledger from deep inside it" (Just base) found+  none <- findBaseDir tmp+  assertEqual "no ledger above the temp dir" Nothing none
test/Common/Unit.hs view
@@ -121,4 +121,13 @@     )  tests :: Test-tests = TestList [testDocURL, testShowCmdArgs, testHledgerConfArgs, testRelativeToBase, testExtractDigits, testGroupValuesBy]+tests = TestList [testDocURL, testShowCmdArgs, testHledgerConfArgs, testRelativeToBase, testExtractDigits, testGroupValuesBy, testTrimToolOutput]++testTrimToolOutput :: Test+testTrimToolOutput =+  TestList+    [ "long line" ~: trimToolOutput (T.replicate 400 "x") ~?= T.replicate 300 "x" <> "…",+      "many lines" ~: T.lines (trimToolOutput (T.unlines (map (T.pack . show) [1 .. 25 :: Int])))+        ~?= map (T.pack . show) [1 .. 20 :: Int] ++ ["… (5 more lines)"],+      "short output unchanged" ~: trimToolOutput "a\nb\n" ~?= "a\nb\n"+    ]
+ test/Environment/Unit.hs view
@@ -0,0 +1,41 @@+{-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE QuasiQuotes #-}++module Environment.Unit (tests) where++import qualified Data.Text as T+import Hledger.Flow.Environment+import Hledger.Flow.RuntimeOptions+import Path+import Test.HUnit+import TestHelpers (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})+      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"))+  assertBool "flag beside its value" (any ("2024 onwards" `T.isInfixOf`) (line "Start year") && any ("--start-year" `T.isSuffixOf`) (line "Start year"))+  assertBool "whole ledger" (any (". (whole ledger)" `T.isInfixOf`) (line "Run from"))+  -- The longest labels are 12 characters, so every value starts in column 14.+  assertBool "values line up" (all (\l -> T.index l 13 == ' ' && T.index l 14 /= ' ') out)++testMissingHledger :: Test+testMissingHledger = TestCase $ do+  let out = renderSettings (\v -> "<" <> v <> ">") (environmentSettings (sysInfo opts) Nothing Nothing (baseDir opts))+  assertBool "missing hledger styled as a problem" (any ("<not found" `T.isInfixOf`) out)+  assertBool "only that line styled" (length (filter ("<" `T.isInfixOf`) out) == 1)++testReportTables :: Test+testReportTables = TestCase $ do+  let out = renderSettings id (reportSettings 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]
+ test/Init/Integration.hs view
@@ -0,0 +1,22 @@+{-# LANGUAGE QuasiQuotes #-}++module Init.Integration (tests) where++import Hledger.Flow.Init (InitOutcome (..), initLedger)+import Path+import Path.IO (doesDirExist)+import Test.HUnit hiding (Path)+import TestHelpers (withTestDir)++testInit :: Test+testInit = TestCase $ withTestDir $ \tmp -> do+  let fresh = tmp </> [reldir|fin|]+  (@?= Created fresh Nothing) =<< initLedger fresh+  doesDirExist (fresh </> [reldir|import|]) >>= assertBool "import/ created"+  (@?= AlreadyLedger fresh) =<< initLedger fresh+  (@?= InsideLedger fresh) =<< initLedger (fresh </> [reldir|import/me|])+  let nested = fresh </> [reldir|elsewhere|]+  (@?= Created nested (Just fresh)) =<< initLedger nested++tests :: Test+tests = TestList [testInit]
+ test/ReleaseNotice/Unit.hs view
@@ -0,0 +1,81 @@+{-# LANGUAGE OverloadedStrings #-}++module ReleaseNotice.Unit (tests) where++import Control.Concurrent.MVar+import Control.Exception (AsyncException (UserInterrupt), throwIO, try)+import Control.Monad (forM_)+import Data.Aeson (encode, object, (.=))+import Data.IORef+import qualified Data.Text as T+import Data.Version (showVersion)+import Paths_hledger_flow (version)+import Hledger.Flow.ReleaseNotice (checkRelease, finishCommand, startCheck)+import Path (toFilePath)+import System.Directory (createDirectoryIfMissing)+import System.Exit (ExitCode (ExitFailure))+import System.FilePath ((</>))+import System.Timeout (timeout)+import Test.HUnit+import TestHelpers (withTestDir)++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)])),+             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+  , "verified Homebrew ownership and published formula" ~: TestCase $+      withTestDir $ \dir -> do+        let keg = toFilePath dir </> "Cellar" </> "hledger-flow" </> "1.2.3"+            exe = keg </> "bin" </> "hledger-flow"+            receipt = keg </> "INSTALL_RECEIPT.json"+            fetch formula url+              | url == "https://api.github.com/repos/apauley/hledger-flow/releases/latest" =+                  pure $ Just "{\"tag_name\":\"v999.10.0\"}"+              | url == "https://raw.githubusercontent.com/FernTrail/homebrew-tap/main/Formula/hledger-flow.rb" =+                  pure formula+              | 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)+            published = Just "  tag = \"v999.10.0\"\n"+            releases = "https://github.com/apauley/hledger-flow/releases/latest"+        createDirectoryIfMissing True (keg </> "bin")+        writeFile exe ""+        check releases published+        writeFile receipt "{\"source\":{\"tap\":\"other/tap\"}}"+        check releases published+        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+  , "command finishes with worker still pending" ~: TestCase $ do+      started <- newEmptyMVar+      release <- newEmptyMVar+      finish <- startCheck (putMVar started () >> takeMVar release)+      takeMVar started+      success <- timeout 1000000 $ finishCommand finish (pure ())+      failure <- timeout 1000000 $ try (finishCommand finish (throwIO (ExitFailure 37)))+      Just () @=? success+      Just (Left (ExitFailure 37)) @=? failure+      putMVar release Nothing+  , "ready notice follows diagnostics and preserves the exit code" ~: TestCase $ do+      events <- newIORef ([] :: [String])+      let record msg = modifyIORef' events (++ [msg])+      result <- try $ finishCommand (record "notice") (record "diagnostic" >> throwIO (ExitFailure 23))+      Left (ExitFailure 23) @=? result+      readIORef events >>= assertEqual "order" ["diagnostic", "notice"]+  , "interrupt skips a ready notice" ~: TestCase $ do+      result <- try $ finishCommand (assertFailure "printed on interrupt") (throwIO UserInterrupt)+      Left UserInterrupt @=? result+  , "notice write failure preserves success and failure" ~: TestCase $ do+      let broken = ioError (userError "closed stderr")+      finishCommand broken (pure ())+      result <- try $ finishCommand broken (throwIO (ExitFailure 19))+      Left (ExitFailure 19) @=? result+  ]
+ test/RulesTemplate/Unit.hs view
@@ -0,0 +1,43 @@+{-# LANGUAGE OverloadedStrings #-}++module RulesTemplate.Unit (tests) where++import qualified Data.Text as T+import Hledger.Flow.RulesTemplate+import Test.HUnit++tests :: Test+tests =+  TestList+    [ "bom stripped" ~: templateFields (snd (splitHeader "\xFEFF\&Date,Desc,Amount")) ~?= ["date", "description", "amount"],+      "semicolons" ~: fst (splitHeader "\"Datum\";\"Omschrijving\";\"Bedrag\"") ~?= ';',+      "tabs" ~: fst (splitHeader "Date\tDesc\tAmount") ~?= '\t',+      "mapping" ~: templateFields ["Transaction Date", "Payee Name", "Amount", "Balance (ZAR)", "", "Amount"]+        ~?= ["date", "description", "amount", "balance_zar", "field5", "bank_amount"],+      "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"],+      "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")+        (rulesTemplate "b" "a" "x.csv" "Date,Desc,Amount,Balance\n")+        ~?= Right True,+      "data row" ~: looksLikeHeader ["2026-01-02", "Coffee", "-3.50"] ~?= False,+      "header row" ~: looksLikeHeader ["Date", "Desc", "Amount"] ~?= True,+      "template fields" ~: fmap (T.isInfixOf "fields date, description, amount\n")+        (rulesTemplate "mybank" "checking" "import/me/mybank/checking/1-in/2026/jan.csv" "Date,Desc,Amount\n1,2,3\n")+        ~?= Right True,+      "template accounts" ~: fmap (T.isInfixOf "account1 assets:mybank:checking\n")+        (rulesTemplate "mybank" "checking" "x.csv" "Date,Desc,Amount\n")+        ~?= Right True,+      "template separator" ~: fmap (T.isInfixOf "separator ;\n")+        (rulesTemplate "b" "a" "x.csv" "Date;Desc;Amount\n")+        ~?= Right True,+      "no separator line for commas" ~: fmap (T.isInfixOf "separator")+        (rulesTemplate "b" "a" "x.csv" "Date,Desc,Amount\n")+        ~?= Right False,+      "no header" ~: either (T.isInfixOf "looks like data") (const False)+        (rulesTemplate "b" "a" "x.csv" "2026-01-02,Coffee,-3.50\n") ~?= True,+      "not csv" ~: either (T.isInfixOf "isn't a CSV file") (const False)+        (rulesTemplate "b" "a" "x.pdf" "%PDF") ~?= True+    ]
test/Spec.hs view
@@ -8,8 +8,14 @@ import qualified CSVImport.Unit import qualified Common.Integration import qualified Common.Unit+import qualified Environment.Unit import qualified ImportHelpers.Integration+import qualified Init.Integration import qualified PathHelpers.Unit+import qualified ReleaseNotice.Unit+import qualified RulesTemplate.Unit+import qualified Status.Integration+import qualified Status.Unit import Test.HUnit import System.Exit (exitFailure) @@ -22,7 +28,13 @@       BaseDir.Integration.tests,       ImportHelpers.Integration.tests,       CSVImport.Unit.tests,-      CSVImport.Integration.tests+      CSVImport.Integration.tests,+      Status.Unit.tests,+      Status.Integration.tests,+      Init.Integration.tests,+      ReleaseNotice.Unit.tests,+      RulesTemplate.Unit.tests,+      Environment.Unit.tests     ]  main :: IO Counts
+ test/Status/Integration.hs view
@@ -0,0 +1,92 @@+{-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE QuasiQuotes #-}++module Status.Integration (tests) where++import Data.Maybe (isNothing)+import Data.Time.Clock (addUTCTime)+import Hledger.Flow.Common (findHledger)+import Hledger.Flow.PathHelpers (AbsFile)+import Hledger.Flow.Status+import Hledger.Flow.Status.Gather (classifyDir, gatherStatus)+import Path+import Path.IO+import Control.Monad (unless)+import System.Environment (lookupEnv, setEnv, unsetEnv)+import qualified System.Info as Sys+import System.FilePath (joinPath)+import Test.HUnit hiding (Path)+import TestHelpers (defaultHlInfo, withTestDir)++touch :: AbsFile -> IO ()+touch file = do+  createDirIfMissing True (parent file)+  writeFile (toFilePath file) "Date,Desc,Amount\n"++testClassify :: Test+testClassify = TestCase $ withTestDir $ \tmp -> do+  let ledger = tmp </> [reldir|ledger|]+      empty = tmp </> [reldir|empty|]+      other = tmp </> [reldir|other|]+  mapM_ (createDirIfMissing True) [ledger </> [reldir|import/me|], empty, other]+  writeFile (toFilePath (empty </> [relfile|.DS_Store|])) ""+  writeFile (toFilePath (other </> [relfile|notes.txt|])) ""+  (@?= InLedger ledger) =<< classifyDir (ledger </> [reldir|import/me|])+  (@?= EmptyDir) =<< classifyDir empty+  (@?= Elsewhere) =<< classifyDir other++testGather :: Test+testGather = TestCase $ withTestDir $ \base -> do+  let imp = base </> [reldir|import|]+      cardCsv = imp </> [relfile|other/bank/card/1-in/2026/c.csv|]+      cardJournal = imp </> [relfile|other/bank/card/3-journal/2026/c.journal|]+  mapM_ touch+    [ imp </> [relfile|me/mybank/checking/1-in/2025/a.csv|],+      imp </> [relfile|me/mybank/checking/1-in/2026/b.csv|],+      cardCsv,+      imp </> [relfile|bank.rules|],+      cardJournal,+      imp </> [relfile|me/mybank/jan.csv|],+      imp </> [relfile|me/_manual_/2026/pre-import.journal|]+    ]+  now <- getModificationTime cardCsv+  setModificationTime cardJournal (addUTCTime (-3600) now)+  unsetEnv "LEDGER_FILE"+  s <- gatherStatus (Just defaultHlInfo) base+  map asAccount (lsAccounts s) @?= ["me/mybank/checking", "other/bank/card"]+  case lsAccounts s of+    [checking, card] -> do+      (asStatements checking, asYears checking, asRulesFile checking, asWithoutRules checking, asJournalsMissing checking)+        @?= (2, [2025, 2026], Nothing, 2, 2)+      asRulesCandidates checking @?= [joinPath ["import", "me", "mybank", "checking", "mybank-checking.rules"], joinPath ["import", "mybank.rules"]]+      (asRulesFile card, asWithoutRules card, asJournalsMissing card, asJournalsOutdated card)+        @?= (Just (joinPath ["import", "bank.rules"]), 0, 0, 1)+    _ -> assertFailure "expected two accounts"+  map mpPath (lsMisplaced s) @?= [joinPath ["import", "me", "mybank", "jan.csv"]]+  lsManualJournals s @?= 1+  lsLedgerFile s @?= Nothing+  setEnv "LEDGER_FILE" (toFilePath (base </> [relfile|missing.journal|]))+  s2 <- gatherStatus Nothing base+  unsetEnv "LEDGER_FILE"+  fmap lfExists (lsLedgerFile s2) @?= Just False+  lsAllYearsExists s2 @?= False++testLedgerFileTilde :: Test+testLedgerFileTilde = TestCase $ unless (Sys.os == "mingw32") $ withTestDir $ \base -> do+  oldHome <- lookupEnv "HOME"+  writeFile (toFilePath (base </> [relfile|x.journal|])) ""+  createDirIfMissing True (base </> [reldir|import|])+  setEnv "HOME" (toFilePath base)+  setEnv "LEDGER_FILE" "~/x.journal"+  s <- gatherStatus Nothing base+  unsetEnv "LEDGER_FILE"+  maybe (unsetEnv "HOME") (setEnv "HOME") oldHome+  fmap lfExists (lsLedgerFile s) @?= Just True++testFindHledgerMissing :: Test+testFindHledgerMissing = TestCase $ do+  r <- findHledger (Just "/nonexistent/hledger")+  assertBool "Nothing for a missing path" (isNothing r)++tests :: Test+tests = TestList [testClassify, testGather, testFindHledgerMissing, testLedgerFileTilde]
+ test/Status/Unit.hs view
@@ -0,0 +1,140 @@+{-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE QuasiQuotes #-}++module Status.Unit (tests, blank, acct) where++import Data.Aeson (Value (..))+import qualified Data.Aeson.Key as Key+import qualified Data.Aeson.KeyMap as KeyMap+import Data.List (sort)+import qualified Data.Text as T+import Hledger.Flow.Common (Shell (..))+import Hledger.Flow.Status.Render (renderStatus, statusJSON)+import Hledger.Flow.Status+import Path+import qualified System.FilePath as FilePath+import Test.HUnit+import TestHelpers (defaultHlInfo, testRoot, testSystemInfo)++blank :: LedgerStatus+blank =+  LedgerStatus+    { lsBaseDir = testRoot </> [reldir|fin|],+      lsHledger = Just defaultHlInfo,+      lsLedgerFile = Just (LedgerFile "/x" True),+      lsAccounts = [],+      lsMisplaced = [],+      lsManualJournals = 0,+      lsAllYearsExists = True,+      lsNeedsReconciliation = False+    }++acct :: AccountStatus+acct =+  AccountStatus+    { asAccount = "me/mybank/checking",+      asStatements = 1,+      asYears = [2026],+      asRulesFile = Just "import/me/mybank/checking/mybank-checking.rules",+      asRulesCandidates = ["import/me/mybank/checking/mybank-checking.rules", "import/mybank.rules"],+      asWithoutRules = 0,+      asPreprocess = False,+      asConstruct = False,+      asJournalsMissing = 0,+      asJournalsOutdated = 0+    }++native :: FilePath -> FilePath+native = FilePath.joinPath . FilePath.splitDirectories++testSuggest :: Test+testSuggest =+  TestList+    [ suggestPlacement ["me", "mybank", "jan.csv"] ~?= native "import/me/mybank/<account>/1-in/<year>/jan.csv",+      suggestPlacement ["jan.csv"] ~?= native "import/<owner>/<bank>/<account>/1-in/<year>/jan.csv",+      suggestPlacement ["me", "mybank", "checking", "2026", "jan.csv"] ~?= native "import/me/mybank/checking/1-in/2026/jan.csv",+      suggestPlacement ["me", "mybank", "checking", "1-in", "jan.csv"] ~?= native "import/me/mybank/checking/1-in/<year>/jan.csv"+    ]++testMisplacedUpperCase :: Test+testMisplacedUpperCase = TestCase $ do+  let root = testRoot </> [reldir|fin/import|]+      files =+        [ root </> [relfile|me/mybank/JAN.CSV|],+          root </> [relfile|me/mybank/checking/1-in/2026/feb.csv|],+          root </> [relfile|me/mybank/checking/2-preprocessed/2026/feb.csv|],+          root </> [relfile|me/mybank/checking/3-journal/2026/feb.journal|],+          root </> [relfile|me/mybank/checking/3-journal/2026/mar.csv|],+          root </> [relfile|me/mybank/notes.md|]+        ]+  map mpPath (misplacedFiles root files) @?=+    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)++summaryOf :: LedgerStatus -> T.Text+summaryOf = nsSummary . atRoot++commandOf :: LedgerStatus -> Maybe T.Text+commandOf = nsCommand . atRoot++allYears :: T.Text+allYears = T.pack (toFilePath (lsBaseDir blank </> [relfile|all-years.journal|]))++testRules :: Test+testRules =+  TestList+    [ "1 hledger missing" ~: summaryOf blank {lsHledger = Nothing} ~?= "Install hledger, or pass its location with --hledger-path",+      "2 empty ledger" ~: summaryOf blank ~?= "Put your first statement in import/<owner>/<bank>/<account>/1-in/<year>/",+      "2 example uses current year" ~: nsDetail (atRoot blank) ~?= ["For example: import/me/mybank/checking/1-in/2026/statement.csv", "Then check with: hledger-flow status"],+      "2 skipped for manual-only ledger" ~: summaryOf blank {lsManualJournals = 1} ~?= "Generate reports, or explore with hledger",+      "5 import when all-years.journal is missing" ~: commandOf blank {lsManualJournals = 1, lsAllYearsExists = False} ~?= Just "hledger-flow import",+      "3 misplaced" ~: summaryOf blank {lsMisplaced = [Misplaced "import/a.csv" "x"]} ~?= "Move 1 file to where hledger-flow looks",+      "3 misplaced plural" ~: summaryOf blank {lsMisplaced = [Misplaced "import/a.csv" "x", Misplaced "import/b.csv" "y"]} ~?= "Move 2 files to where hledger-flow looks",+      "3 detail" ~: nsDetail (atRoot blank {lsMisplaced = [Misplaced "import/a.csv" "x"]}) ~?= ["import/a.csv → x"],+      "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)"}]})+          ~?= 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 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"+    ]++testVersion :: Test+testVersion =+  TestList+    [ hledgerVersionNumber "hledger 1.50.2, mac-aarch64" ~?= "1.50.2",+      hledgerVersionNumber "1.50" ~?= "1.50"+    ]++tests :: Test+testRenderNoColour :: Test+testRenderNoColour = TestCase $ do+  let s = blank {lsAccounts = [acct {asWithoutRules = 1, asRulesFile = Nothing}]}+      out = renderStatus False Nothing 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)+  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)++testJSONShape :: Test+testJSONShape = TestCase $ case statusJSON testSystemInfo Nothing blank (atRoot blank) of+  Object o ->+    sort (map Key.toText (KeyMap.keys o))+      @?= ["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]