diff --git a/base/build.sbt b/base/build.sbt index 846d275f..7436b61a 100644 --- a/base/build.sbt +++ b/base/build.sbt @@ -5,4 +5,5 @@ libraryDependencies += Dependencies.config libraryDependencies += scalaXML libraryDependencies += scalaParserCombinators libraryDependencies += scalatest % "test" +libraryDependencies += scalamock % "test" diff --git a/base/src/main/scala/co/uproot/abandon/Ast.scala b/base/src/main/scala/co/uproot/abandon/Ast.scala index 24104f92..a7ae186c 100644 --- a/base/src/main/scala/co/uproot/abandon/Ast.scala +++ b/base/src/main/scala/co/uproot/abandon/Ast.scala @@ -219,4 +219,4 @@ case class Scope(entries: Seq[ASTEntry], parentOpt: Option[Scope]) extends ASTEn def childScopes = Helper.filterByType[Scope](entries) def allTransactions:Seq[ScopedTxn] = allLocalTransactions ++ childScopes.flatMap(_.allTransactions) -} \ No newline at end of file +} diff --git a/base/src/main/scala/co/uproot/abandon/Config.scala b/base/src/main/scala/co/uproot/abandon/Config.scala index 32a013e6..4bc7cc3d 100644 --- a/base/src/main/scala/co/uproot/abandon/Config.scala +++ b/base/src/main/scala/co/uproot/abandon/Config.scala @@ -1,12 +1,16 @@ package co.uproot.abandon +import java.time.LocalDate +import java.time.format.DateTimeFormatter + import com.typesafe.config.Config import com.typesafe.config.ConfigFactory + import collection.JavaConverters._ -import com.typesafe.config.ConfigObject import org.rogach.scallop.ScallopConf import SettingsHelper._ import com.typesafe.config.ConfigException +import scala.util.Try class AbandonCLIConf(arguments: Seq[String]) extends ScallopConf(arguments) { val inputs = opt[List[String]]("input", short = 'i') @@ -69,16 +73,17 @@ object SettingsHelper { if (file.exists) { try { val config = ConfigFactory.parseFile(file).resolve() - val inputs = config.getStringList("inputs").asScala.map(handleInput(_, configFileName)).flatten.toSeq.sorted + val inputs = config.getStringList("inputs").asScala.flatMap(handleInput(_, configFileName)).toSeq.sorted val reports = config.getConfigList("reports").asScala.map(makeReportSettings(_)) val reportOptions = config.optConfig("reportOptions") - val isRight = reportOptions.map(_.optStringList("isRight")).flatten.getOrElse(Nil) + val isRight = reportOptions.flatMap(_.optStringList("isRight")).getOrElse(Nil) val exportConfigs = config.optConfigList("exports").getOrElse(Nil) val exports = exportConfigs.map(makeExportSettings) val accountConfigs = config.optConfigList("accounts").getOrElse(Nil) val accounts = accountConfigs.map(makeAccountSettings) val eodConstraints = config.optConfigList("eodConstraints").getOrElse(Nil).map(makeEodConstraints(_)) - Right(Settings(inputs, eodConstraints, accounts, reports, ReportOptions(isRight), exports, Some(file))) + val dateConstraints = config.optConfigList("dateConstraints").getOrElse(Nil).map(makeDateConstraints(_)) + Right(Settings(inputs, eodConstraints ++ dateConstraints, accounts, reports, ReportOptions(isRight), exports, Some(file))) } catch { case e: ConfigException => Left(e.getMessage) } @@ -96,6 +101,13 @@ object SettingsHelper { } } + def makeDateConstraints(config: Config): DateConstraint = { + val from = AbandonParser.dateExpr(ParserHelper.scanner(config.getString("from"))).map(Some(_)).getOrElse(None) + val to = AbandonParser.dateExpr(ParserHelper.scanner(config.getString("to"))).map(Some(_)).getOrElse(None) + + DateConstraint(from, to) + } + def makeReportSettings(config: Config) = { val title = config.getString("title") val reportType = config.getString("type") @@ -204,9 +216,27 @@ case class NegativeConstraint(val accName: String) extends Constraint with SignC val signStr = "negative" } +case class DateConstraint(dateFrom: Option[Date], dateTo: Option[Date]) extends Constraint { + override def check(appState: AppState): Boolean = { + appState.accState.posts.find(post => { + val date = post.date + + dateFrom.map(DateOrdering.compare(_, date) > 0).getOrElse(false) || dateTo.map(DateOrdering.compare(_, date) < 0).getOrElse(false) + }).foreach(post => { + throw new ConstraintError( + s"${post.name} is not in date range of " + + s"[${dateFrom.getOrElse("None")}, ${dateTo.getOrElse("None")}]. " + + s"Date is ${post.date.formatISO8601Ext}" + ) + }) + + true + } +} + case class Settings( inputs: Seq[String], - eodConstraints: Seq[Constraint], + constraints: Seq[Constraint], accounts: Seq[AccountSettings], reports: Seq[ReportSettings], reportOptions: ReportOptions, diff --git a/base/src/main/scala/co/uproot/abandon/Parser.scala b/base/src/main/scala/co/uproot/abandon/Parser.scala index 9a2b7750..ce45d7e4 100644 --- a/base/src/main/scala/co/uproot/abandon/Parser.scala +++ b/base/src/main/scala/co/uproot/abandon/Parser.scala @@ -211,12 +211,12 @@ object AbandonParser extends StandardTokenParsers with PackratParsers { private lazy val conditionExpr = (numericExpr ~ comparisonExpr ~ numericExpr) ^^ { case (e1 ~ op ~ e2) => ConditionExpr(e1, op, e2)} private lazy val comparisonExpr: PackratParser[String] = ((">" | "<" | "=") ~ "=" ^^ {case (o1 ~ o2) => o1 + o2}) | ">" | "<" - private lazy val compactTxFrag = (("." ~> (dateFrag | isoDateFrag) ~ accountName ~ numericExpr ~ eolComment) ^^ { + private lazy val compactTxFrag = (("." ~> dateExpr ~ accountName ~ numericExpr ~ eolComment) ^^ { case date ~ accountName ~ amount ~ optComment => Transaction(date, List(Post(accountName, Option(amount), optComment)), None, None, Nil) }) - private lazy val txFrag = (((dateFrag | isoDateFrag) ~ (annotation?) ~ (payee?)) <~ eol) ~ (eolComment*) ~ (post+) ^^ { + private lazy val txFrag = ((dateExpr ~ (annotation?) ~ (payee?)) <~ eol) ~ (eolComment*) ~ (post+) ^^ { case date ~ annotationOpt ~ optPayee ~ optComment ~ posts => val annotationStrOpt = annotationOpt.map(_.mkString("")) Transaction(date, posts, annotationStrOpt, optPayee, optComment.flatten) @@ -230,6 +230,8 @@ object AbandonParser extends StandardTokenParsers with PackratParsers { case name ~ amount ~ commentOpt => Post(name, amount, commentOpt) } + lazy val dateExpr = dateFrag | isoDateFrag + lazy val isoDateFrag = ((((integer <~ "-") ~ integer) <~ "-") ~ integer) ^? ({ case y ~ m ~ d if (isValidDate(y, m, d)) => Date(y, m, d) diff --git a/base/src/test/scala/co/uproot/abandon/DateConstraintTest.scala b/base/src/test/scala/co/uproot/abandon/DateConstraintTest.scala new file mode 100644 index 00000000..6aa377e6 --- /dev/null +++ b/base/src/test/scala/co/uproot/abandon/DateConstraintTest.scala @@ -0,0 +1,67 @@ +package co.uproot.abandon + +import java.time.{LocalDate, Month} + +import org.scalamock.scalatest.MockFactory +import org.scalatest._ + +class DateConstraintTest extends FlatSpec with Matchers with BeforeAndAfterEach with MockFactory with OneInstancePerTest { + + class MockableDetailedPost extends DetailedPost(new AccountName(Seq()), 0, None, None) + + var appState: AppState = null + + override def beforeEach() { + setupMocks() + } + + it should "return true on valid posts" in { + val constraint = new DateConstraint( + Some(Date(2013, 1, 1)), + Some(Date(2013, 12, 31)) + ) + + constraint.check(appState) should be(true) + } + + it should "throw exception on end date violation" in { + val constraint = new DateConstraint( + Some(Date(2013, 1, 1)), + Some(Date(2013, 11, 1)) + ) + an [ConstraintError] should be thrownBy constraint.check(appState) + + val constraintWithoutFrom = new DateConstraint(None, Some(Date(2013, 11, 1))) + an [ConstraintError] should be thrownBy constraintWithoutFrom.check(appState) + } + + it should "throw exception on start date violation" in { + val constraint = new DateConstraint( + Some(Date(2013, 6, 1)), + Some(Date(2013, 11, 1)) + ) + an [ConstraintError] should be thrownBy constraint.check(appState) + + val constraintWithoutTo = new DateConstraint(Some(Date(2013, 11, 1)), None) + an [ConstraintError] should be thrownBy constraintWithoutTo.check(appState) + } + + it should "return true on empty dates" in { + val constraintWithoutTo = new DateConstraint(None, None) + constraintWithoutTo.check(appState) should be(true) + } + + + def setupMocks() { + val accState = stub[AccountState] + appState = new AppState(accState) + + val posts: Seq[DetailedPost] = (1 to 12).map(month => { + val post = stub[MockableDetailedPost] + (post.date _).when().returns(new Date(2013, month, 1)) + post + }) + + (accState.posts _).when().returns(posts) + } +} diff --git a/cli/src/main/scala/co/uproot/abandon/App.scala b/cli/src/main/scala/co/uproot/abandon/App.scala index a5b3c3da..26e21839 100644 --- a/cli/src/main/scala/co/uproot/abandon/App.scala +++ b/cli/src/main/scala/co/uproot/abandon/App.scala @@ -151,7 +151,7 @@ object CLIMain { val (parseError, astEntries, processedFiles) = Processor.parseAll(settings.inputs) if (!parseError) { val appState = Processor.process(astEntries,settings.accounts) - Processor.checkConstaints(appState, settings.eodConstraints) + Processor.checkConstaints(appState, settings.constraints) settings.exports.foreach { exportSettings => val reportWriter = new ReportWriter(settings, exportSettings.outFiles) println() diff --git a/examples/README.md b/examples/README.md index 922d3358..fd79425d 100644 --- a/examples/README.md +++ b/examples/README.md @@ -3,4 +3,4 @@ There are two examples in this directory. If you are new to `abandon` then have a look at the `simple` example first. The `complete` example showcases many features of abandon, and can be used as a reference -for managing your acutal accounts. +for managing your actual accounts. diff --git a/examples/complete/accounts.conf b/examples/complete/accounts.conf index 75a0f5f2..0c111526 100644 --- a/examples/complete/accounts.conf +++ b/examples/complete/accounts.conf @@ -67,3 +67,8 @@ exports += { }] } + +dateConstraints += { + from = "2012-01-01" + to = "2013-12-31" +} \ No newline at end of file diff --git a/examples/simple/accounts.conf b/examples/simple/accounts.conf index 186e1315..69d3b3e0 100644 --- a/examples/simple/accounts.conf +++ b/examples/simple/accounts.conf @@ -16,4 +16,3 @@ reports += { type = balance accountMatch = ["^Income.*", "^Expenses.*"] } - diff --git a/project/Dependencies.scala b/project/Dependencies.scala index 653baca7..ce4fe4f4 100644 --- a/project/Dependencies.scala +++ b/project/Dependencies.scala @@ -3,8 +3,9 @@ import Keys._ object Dependencies { // Versions - val scalatestVersion = "3.0.0" + val scalatestVersion = "2.2.6" val scallopVersion = "2.0.2" + val scalaMockVersion = "3.2.2" val configVersion = "1.3.0" val scalaXMLVersion = "1.0.5" val scalaParserCombinatorsVersion = "1.0.4" @@ -12,6 +13,7 @@ object Dependencies { // Libraries val scalatest = "org.scalatest" %% "scalatest" % scalatestVersion + val scalamock = "org.scalamock" %% "scalamock-scalatest-support" % scalaMockVersion val scallop = "org.rogach" %% "scallop" % scallopVersion val config = "com.typesafe" % "config" % configVersion val scalaXML = "org.scala-lang.modules" %% "scala-xml" % scalaXMLVersion