diff --git a/.gitignore b/.gitignore index 5db623a..af2dc18 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,6 @@ /tmp/ *.lock *.mps +/vendor/ +**/**/*.gem + diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..bade988 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,102 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +MPS (MonoPsyches) is a Ruby gem — a plain-text personal productivity CLI. Users store tasks, notes, reminders, and logs in date-stamped `.mps` files (e.g. `20260226.1730000000.mps`) inside a configurable storage directory (`~/.mps/mps/` by default). Files are opened in Vim; git integration handles sync. + +## Commands + +```bash +# Install dependencies +bundle install + +# Run all tests +bundle exec rake test:with_groups + +# Run a single test file +bundle exec ruby -Itest -Ilib test/config_test.rb + +# Run a single test by name +bundle exec ruby -Itest -Ilib test/config_test.rb -n test_name + +# Build the gem +gem build mps.gemspec + +# Run the CLI locally +bundle exec exe/mps +``` + +```bash +# List parsed elements from today's (or a given date's) file +bundle exec exe/mps list [DATESIGN] [--type task|note|log|reminder] + +# Append a single element without opening Vim +bundle exec exe/mps append TYPE BODY [--tags work,release] [--at 3pm] + +# Auto stage, commit, pull, and push inside storage_dir +bundle exec exe/mps autogit +``` + +Set `MPS_DEBUG=true` to enable verbose `require_relative` tracing via the custom `ir()` loader. + +## Architecture + +### Entry point +`exe/mps` calls `MPS::CLI::MPS.start(ARGV)` — a Thor-based CLI defined in `lib/cli/mps.rb`. All commands (`open`, `git`, `autogit`, `list`, `append`, `cmd`, `version`) live there and delegate to the library layer. + +### Custom loader (`ir`) +`lib/mps/mps.rb` defines a global helper `ir(relative_path)` that wraps `require_relative` with caller-location resolution. All internal requires use `ir` instead of `require_relative`. This is intentional — don't replace it with standard requires. + +### Load order (`lib/mps.rb`) +``` +mps/version → mps/mps (defines ir + MPS module methods) → + mps/constants → mps/config → mps/interpolators → mps/elements → mps/engines → cli/mps +``` + +### Parsing pipeline (`lib/mps/engines/mps.rb`) +`Engines::MPS.parse_mps_file_to_elments_hash` reads an `.mps` file and uses `StringScanner` to tokenize it. It wraps the raw file contents in a synthetic `@mps[]{}` root element, then does a single-pass scan driven by two regexes from `Constants`: `AT_REGEXP_LA` (lookahead for `@element[args]{`) and `END_CURLY_REGEXP_LA` (lookahead for `}`). A stack tracks nesting; each closed element is instantiated and stored in a flat hash keyed by a dotted ref path (e.g. `"1234567890.1.2"`). + +### Elements (`lib/mps/elements/`) +Each element type (Task, Note, Reminder, Log, MPS) is a class that `include`s the `MPS::Element` mixin. The mixin provides `initialize(args:, refs:, body_str:)`, `display_str`, and `attr_reader :body_str`. Each class must define `SIGNATURE_STAMP` (used when generating filenames/wrapping) and `SIGNATURE_REGEX` (matched against the parsed element sign to dispatch). The engine discovers all element classes dynamically via `MPS::Elements.constants`. + +### Interpolators (`lib/mps/interpolators/`) +Interpolator classes (e.g. `Interpolators::Time`) are discovered the same way as elements — via `const_get` on the module's `constants`. Each defines `SIGNATURE_REGEX` and `get_str(**ref)`. They are loaded into `Engines::MPS` but the interpolation call-site is not yet wired up in the engine. + +### Configuration (`lib/mps/config.rb` + `lib/mps/constants.rb`) +`Config.init(path)` writes a default YAML config. `Config.new(**hash)` holds `storage_dir`, `mps_dir`, `log_file`, and a `Logger`. Two additional optional YAML keys are supported: `git_remote` (default `"origin"`) and `git_branch` (default `"master"`); both are exposed as `attr_reader`s and used by `git` and `autogit` commands. The CLI re-reads the config on every invocation via `load_config`; it auto-creates missing directories and the log file. + +## File format + +``` +@task[tag1, tag2]{ + Task body text +} + +@note{ + Free-form note +} + +@reminder[at: 3pm]{ + Meeting description +} + +@log[start: 09:00, end: 12:30]{ + Time log entry +} + +@mps{ + @task{ nested task } +} +``` + +File names follow `YYYYMMDD..mps`; the epoch disambiguates multiple files per day. The regexp for valid names is `Constants::MPS_FILE_NAME_REGEXP`. + +## Testing + +Tests use Minitest with `fakefs` for filesystem isolation. The test helper at `test/test_helper.rb` sets up the load path and does `include MPS`, so all constants and module methods are available directly in test classes. + +`test/engine_test.rb` tests the parser directly: it uses a `parse_content(str)` helper that writes a fixture file under `FakeFS` at `/tmp/20260101.mps` and calls `Engines::MPS.parse_mps_file_to_elments_hash` on it. Covers empty files, single and multiple elements, unknown element fallback to `Struct`, nested `@mps{}`, `matched_element_class`, and `look_ahead_pos` edge cases. + +`test/config_test.rb` covers `Config.init`, `Config.load_conf_hash` (including `git_remote`/`git_branch` defaults), logger formatting, and `LoadError` on missing keys. diff --git a/GETTING_STARTED.md b/GETTING_STARTED.md new file mode 100644 index 0000000..18df6cb --- /dev/null +++ b/GETTING_STARTED.md @@ -0,0 +1,447 @@ +# Getting Started with MPS + +MPS is a plain-text productivity system that lives in your terminal. Your tasks, notes, reminders, and logs are just `.mps` files in a folder — readable, portable, and git-backed. No app to install, no account to create, no sync service to trust. + +--- + +## Install + +```bash +gem install mps +``` + +First run creates everything automatically: + +``` +~/.mps_config.yaml ← your config +~/.mps/mps/ ← where your files live +~/.mps/mps.log ← activity log +``` + +--- + +## The file format + +Before anything else, it helps to know what you're working with. Each `.mps` file is plain text. Every entry is an **element** — a type, optional arguments in `[]`, and a body in `{}`: + +``` +@task[work, release]{ + Ship the API refactor +} + +@note{ + The auth token expiry edge case needs a second look +} + +@reminder[at: 10am]{ + Team standup +} + +@log[start: 09:00, end: 12:30]{ + Debugging the auth flow +} + +@mps{ + @task[backend]{ + Nested task inside a sub-block + } +} +``` + +The brackets are optional — `@task{ body }` is perfectly valid. Elements nest freely. Files are named `YYYYMMDD..mps` — the epoch allows multiple files per day without collision. + +--- + +## A day in the life + +It's Monday morning. You open your terminal: + +```bash +mps +``` + +Vim opens with today's file — `20260428.1745000000.mps`. You write your morning plan, save, and quit. That's your day started. + +If you want a specific date instead: + +```bash +mps open yesterday +mps open "last monday" +mps open 20260421 +mps open "2 days ago" +``` + +Natural language dates work everywhere in MPS, powered by [Chronic](https://github.com/mojombo/chronic). Anything Chronic understands, MPS understands. + +--- + +## Reading what you wrote + +You're two hours into the day and forget whether you wrote down that reminder. Don't open Vim — just ask: + +```bash +mps list +``` + +``` + [task] (open) Review the API pull request [work] + [reminder] (10am) Team standup + [note] The auth token expiry edge case needs a second look + [@mps] + [task] (open) Nested backend task [backend] +``` + +The nested tree is preserved — child elements appear indented under their parent `[@mps]` group. Each type gets its own color in the terminal. + +### Filter by type + +Only want tasks? + +```bash +mps list --type task +# short form: +mps list -t task +``` + +### Filter by tag + +Everything tagged `work`: + +```bash +mps list --tag work +mps list -g work +``` + +### Filter by status (tasks only) + +See only what's still open: + +```bash +mps list --status open +mps list -s done +``` + +Status filtering only applies to tasks — notes, logs, and reminders are excluded when you use `--status`. + +### Look at a different day + +```bash +mps list yesterday +mps list "last friday" +mps list 20260421 +``` + +### Date ranges with `--since` + +Want everything from the last week up to today? + +```bash +mps list --since "last monday" +``` + +This prints a date header for each day that has entries: + +``` +── 2026-04-25 ───────────── + [task] (done) Set up CI pipeline [devops] +── 2026-04-28 ───────────── + [task] (open) Review the API pull request [work] + [note] Token expiry edge case +``` + +Combine filters freely: `mps list --since yesterday --type task --status open` shows all open tasks from yesterday to today. + +--- + +## Quick-capture without opening Vim + +You're deep in a debugging session. A thought hits you. You don't want to break your flow: + +```bash +mps append note "Check if the race condition only happens under load" +``` + +``` + appended [note] Check if the race condition only happens under load +``` + +The note lands at the bottom of today's file. + +### Append with tags + +```bash +mps append task "Fix the token expiry bug" --tags work,backend +``` + +### Append a task with status + +Already done? Mark it immediately: + +```bash +mps append task "Reviewed the PR" --tags work --status done +``` + +### Log your time + +Log a focused work session with start and end times: + +```bash +mps append log "Deep work on auth refactor" --tags work --start-time 09:00 --end-time 12:30 +``` + +The 3h30m duration is computed automatically and shown in `list`, `stats`, and `export`. + +### Set a timed reminder + +```bash +mps append reminder "Push the hotfix before EOD" --at "5pm" +``` + +All types supported by `append`: `task`, `note`, `log`, `reminder`. + +--- + +## Search across all your files + +End of quarter. You vaguely remember logging something about "auth" in March. You don't know which file. + +```bash +mps search "auth" +``` + +``` +2026-04-28 [log] (3h30m) Debugging the auth flow [work, backend] +2026-04-21 [task] (done) Fix auth token expiry [backend] +(2 results) +``` + +Every `.mps` file in your storage directory is searched. Results show the date, type badge, and first line of the body. + +### Narrow the search + +Filter to a specific type: + +```bash +mps search "auth" --type log +mps search "auth" -t task +``` + +Filter to a tag: + +```bash +mps search "auth" --tag backend +mps search "auth" -g work +``` + +Limit to recent files: + +```bash +mps search "auth" --since "last month" +mps search "auth" -S "2026-04-01" +``` + +All filters compose: `mps search "auth" --type task --tag backend --since "last week"`. + +--- + +## Your productivity at a glance + +Friday afternoon. How did your week go? + +```bash +mps stats --since monday +``` + +``` +2026-04-25 — 2 tasks (1 open, 1 done), 1 note, 1 log (2h) +2026-04-26 — 1 task (0 open, 1 done), 2 logs (5h30m) +2026-04-28 — 3 tasks (2 open, 1 done), 1 note, 1 reminder, 1 log (3h30m) +──────────────────────────────────────────────── +Total: 6 tasks, 3 notes, 1 reminder, 3 logs (11h total) +``` + +Open vs done task counts, total logged hours, everything in one view. + +For a single day: + +```bash +mps stats +mps stats yesterday +mps stats 20260421 +``` + +--- + +## Export your data + +Need to feed your `.mps` data into a spreadsheet, script, or another tool? + +```bash +mps export --format json +``` + +```json +[ + { + "date": "2026-04-28", + "ref": "1745000000.1", + "type": "task", + "tags": "work", + "body": "Review the API pull request", + "status": "open" + }, + ... +] +``` + +CSV format: + +```bash +mps export --format csv +``` + +``` +date,ref,type,tags,body,status,at,start,end +2026-04-28,1745000000.1,task,work,Review the API pull request,open,,, +2026-04-28,1745000000.2,reminder,,Team standup,,10am,, +``` + +All the same filters apply: + +```bash +# Export all tasks this week +mps export --since monday --type task --format csv > this_week_tasks.csv + +# Export everything from a specific day as JSON +mps export 20260421 --format json > april21.json + +# Pipe into jq +mps export --since "last month" --format json | jq '[.[] | select(.status == "done")]' +``` + +--- + +## Git backup — one command + +Your files live in `~/.mps/mps/`. Initialize that directory as a git repo once, then let MPS handle sync: + +```bash +mps autogit +``` + +This does: `git add .` → `git commit -m "$(date)"` → `git pull` → `git push`. Run it at the end of each day. + +For manual control: + +```bash +mps git status +mps git log --oneline -5 +mps git commit -m "end of sprint retrospective" +mps git auto # same as autogit +mps git autocommit # stage and commit only, no push +``` + +Every `mps git` command runs inside your storage directory — no `cd` needed. + +### Configure your remote and branch + +By default MPS pushes to `origin` on `master`. To use a different remote or branch, edit `~/.mps_config.yaml`: + +```yaml +mps_dir: /home/you/.mps +storage_dir: /home/you/.mps/mps +log_file: /home/you/.mps/mps.log +git_remote: origin +git_branch: main +``` + +--- + +## Run any shell command in your storage directory + +Need to see what files exist, or grep across everything raw? + +```bash +mps cmd ls -la +mps cmd grep -r "token expiry" . +mps cmd wc -l *.mps +``` + +Everything runs inside `~/.mps/mps/`. + +--- + +## Version + +```bash +mps version +``` + +--- + +## Full reference + +### Commands + +| Command | What it does | +|---------|-------------| +| `mps` / `mps open [date]` | Open a date's file in Vim (default: today) | +| `mps list [date]` | Print elements in tree order (default: today) | +| `mps append TYPE BODY` | Add one element to today's file without Vim | +| `mps search QUERY` | Full-text search across all files | +| `mps stats [date]` | Element counts and log durations for a date | +| `mps export [date]` | Export elements as JSON or CSV to stdout | +| `mps autogit` | Stage, commit, pull, push in one shot | +| `mps git ARGS` | Run any git command inside storage dir | +| `mps cmd ARGS` | Run any shell command inside storage dir | +| `mps version` | Print current version | + +### list options + +| Option | Short | Description | +|--------|-------|-------------| +| `--type TYPE` | `-t` | Filter by: `task`, `note`, `log`, `reminder` | +| `--tag TAG` | `-g` | Filter by tag name | +| `--status STATUS` | `-s` | Filter tasks by: `open`, `done` | +| `--since DATESIGN` | `-S` | Show elements from SINCE up to DATESIGN | + +### append options + +| Option | Description | +|--------|-------------| +| `--tags t1,t2` | Comma-separated tags | +| `--status open\|done` | Task status (default: open) | +| `--at TIME` | Time for reminders (e.g. `5pm`) | +| `--start-time HH:MM` | Start time for logs | +| `--end-time HH:MM` | End time for logs | + +### search options + +| Option | Short | Description | +|--------|-------|-------------| +| `--type TYPE` | `-t` | Filter by element type | +| `--tag TAG` | `-g` | Filter by tag | +| `--since DATESIGN` | `-S` | Search from this date onward | + +### stats options + +| Option | Short | Description | +|--------|-------|-------------| +| `--since DATESIGN` | `-S` | Stats from SINCE up to DATESIGN | + +### export options + +| Option | Short | Description | +|--------|-------|-------------| +| `--format FORMAT` | `-f` | Output format: `json` (default), `csv` | +| `--type TYPE` | `-t` | Filter by element type | +| `--since DATESIGN` | `-S` | Export from SINCE up to DATESIGN | + +### Date formats accepted everywhere + +| Input | Meaning | +|-------|---------| +| `today`, `yesterday` | Relative day | +| `monday`, `last friday` | Day of week | +| `2 days ago`, `last week` | Natural language | +| `20260421` | Explicit YYYYMMDD | diff --git a/IMPROVEMENTS.md b/IMPROVEMENTS.md new file mode 100644 index 0000000..5e61487 --- /dev/null +++ b/IMPROVEMENTS.md @@ -0,0 +1,90 @@ +# MPS Improvements & Feature Roadmap + +## Bugs + +| # | File | Issue | Severity | +|---|------|-------|----------| +| B1 | `lib/mps/elements/*.rb` | `# frozen string_literal: true` missing the `_` — magic comment is silently ignored | Medium | +| B2 | `lib/cli/mps.rb:61` | `git pull orign master` — typo `orign` instead of `origin` | High | +| B3 | `lib/mps/engines/mps.rb:12` | Uses `eval("::MPS::Elements::#{k}")` — unsafe; use `const_get` | Medium | +| B4 | `test/config_test.rb` | All test bodies are commented out — zero test coverage on Config | High | +| B5 | `lib/mps/interpolators/` | Interpolators are loaded into `Engines::MPS` but never invoked anywhere | Medium | + +## Code Quality + +| # | Issue | Fix | +|---|-------|-----| +| Q1 | `ir()` is defined as a bare global method in `lib/mps/mps.rb` — pollutes `Object` | Move inside `MPS` module as `MPS.ir` or keep but at least document it | +| Q2 | Element classes are identical boilerplate — only `SIGNATURE_STAMP` / `SIGNATURE_REGEX` differ | Extract a `MPS::Elements.define(stamp)` factory or use a shared DSL | +| Q3 | `Engines::MPS` class name clashes with `Elements::MPS` class — confusing namespace | Rename engine to `Engines::Parser` | +| Q4 | `Exception` is rescued in every CLI command instead of `StandardError` — catches `SignalException`, `SystemExit` | Use `StandardError` | +| Q5 | Config is re-read on every CLI invocation but never cached or validated beyond key presence | Add type-checked struct | + +## New Features + +### F1 — `list` command +Display parsed elements from any `.mps` file in the terminal, with optional type filter. + +``` +mps list # today's file +mps list yesterday # yesterday's file +mps list 20260226 --type task +``` + +### F2 — `search` command +Full-text search across all `.mps` files in storage, return matching elements. + +``` +mps search "meeting" +mps search "deploy" --type task --since 7.days.ago +``` + +### F3 — `stats` command +Print a summary of element counts per type for a date (or range). + +``` +mps stats # today +mps stats --since last.week +``` + +### F4 — `append` command (non-Vim fast entry) +Append a single element to today's file without opening Vim. + +``` +mps append task "Write release notes" --tags work,release +mps append note "Idea: dark mode" +mps append reminder "Standup" --at "9am" +``` + +### F5 — Typed element attribute parsing +Currently `@log[start: 09:00, end: 12:30]` args are raw strings. Parse them into typed structs so `list`/`stats` can compute duration, countdown to reminder time, etc. + +### F6 — Export command +Serialize parsed elements to JSON or CSV. + +``` +mps export --format json > today.json +mps export --format csv --since last.week > week.csv +``` + +### F7 — Configurable branch / remote for git sync +`git auto` hardcodes `master` and `origin`. Make both configurable in `~/.mps_config.yaml`. + +```yaml +git_remote: origin +git_branch: main +``` + +## Implementation Priority + +1. **B1, B2, B3** — straightforward, low-risk fixes +2. **Q4** — safety (rescue `StandardError` not `Exception`) +3. **B4** — restore test coverage (Config + Engine parser) +4. **F1 `list`** — most immediately useful, builds on existing parser +5. **F4 `append`** — second most useful daily-driver feature +6. **F7 git config** — removes hardcoded branch/remote +7. **Q3 rename engine** — minor breaking internal rename +8. **F5 typed args** — enables F1/F3 to show richer output +9. **F2 `search`** — builds on F1 infrastructure +10. **F3 `stats`** — builds on F2 +11. **F6 `export`** — nice-to-have diff --git a/README.md b/README.md index e4be00d..c9a7b60 100644 --- a/README.md +++ b/README.md @@ -1,157 +1,183 @@ # MPS (MonoPsyches) -MPS (MonoPsyches) is a plain-text based personal productivity management system. It allows you to organize tasks, notes, reminders, and logs in simple text files organized by date, similar to org-mode or journaling systems. +MPS is a plain-text personal productivity CLI. Tasks, notes, reminders, and logs live in date-stamped `.mps` files stored in `~/.mps/mps/`. Files are opened in Vim; a git integration handles sync. Everything is plain text — no app, no database, no account. ## Features -- **Date-based organization**: Each day gets its own `.mps` file (e.g., `20260226.1730000000.mps`) -- **Structured elements**: Tasks, notes, reminders, logs, and nested MPS entries -- **Vim integration**: Opens files directly in Vim for quick editing -- **Git integration**: Auto-commit, push, and pull your data -- **CLI interface**: Simple commands to manage your MPS files -- **Plain text storage**: Your data is stored as plain text files +- **Date-based files** — one or more `.mps` files per day (`20260428.1745000000.mps`) +- **Structured elements** — tasks (with status), notes, reminders (with time), logs (with duration) +- **Nested elements** — `@mps{ @task{ ... } }` for grouping; `list` renders the tree +- **Typed argument parsing** — tags and named attrs (`status: done`, `at: 5pm`, `start: 09:00, end: 12:30`) +- **Full command set** — `list`, `append`, `search`, `stats`, `export`, `open`, `git`, `autogit`, `cmd` +- **Date ranges** — every listing/search/stats/export command accepts `--since` for multi-day views +- **Git integration** — configurable remote and branch; `autogit` stages, commits, pulls, and pushes in one shot +- **Plain text storage** — grep it, pipe it, open it in any editor ## Installation -Add this line to your application's Gemfile: +```bash +gem install mps +``` + +Or add to your Gemfile: ```ruby gem 'mps' ``` -And then execute: +## Quick start ```bash -bundle install +mps # open today's file in Vim +mps list # print today's elements (nested tree) +mps append task "Fix the token bug" --tags backend --status open +mps search "token" --type task --since "last week" +mps stats --since monday +mps export --format csv --since "2026-04-01" > april.csv +mps autogit # stage + commit + pull + push ``` -Or install it yourself as: +See [GETTING_STARTED.md](GETTING_STARTED.md) for a full walkthrough with examples. + +## Commands + +| Command | Description | +|---------|-------------| +| `mps [open] [date]` | Open a date's file in Vim (default: today) | +| `mps list [date]` | Print elements as an indented tree | +| `mps append TYPE BODY` | Append one element to today's file without Vim | +| `mps search QUERY` | Full-text search across all `.mps` files | +| `mps stats [date]` | Element counts and log durations | +| `mps export [date]` | JSON or CSV to stdout | +| `mps autogit` | Stage, commit, pull, push | +| `mps git ARGS` | Any git command inside storage dir | +| `mps cmd ARGS` | Any shell command inside storage dir | +| `mps version` | Print version | + +## File format -```bash -gem install mps ``` +@task[work, release]{ + Ship the API refactor +} -## Usage +@note{ + The auth token expiry edge case needs a second look +} -### Quick Start +@reminder[at: 10am]{ + Team standup +} -After installation, simply run: +@log[start: 09:00, end: 12:30]{ + Debugging the auth flow +} -```bash -mps +@mps{ + @task[backend]{ + Nested task inside a sub-block + } +} ``` -This opens today's MPS file in Vim. +Brackets are optional — `@task{ body }` is valid. Elements nest freely. -### Available Commands +## Configuration -#### Open an MPS file +On first run MPS writes `~/.mps_config.yaml`: -```bash -mps open [date] +```yaml +mps_dir: ~/.mps +storage_dir: ~/.mps/mps +log_file: ~/.mps/mps.log +git_remote: origin +git_branch: main ``` -Opens the MPS file for the specified date. Examples: +## Architecture -```bash -mps open # Opens today's file -mps open today # Opens today's file -mps open yesterday # Opens yesterday's file -mps open 20260226 # Opens file for February 26, 2026 -mps open "2 days ago" -mps open "last week" -``` +MPS follows a layered architecture: parser → element types → store → CLI. -#### Git Integration +### Load order -```bash -mps git status # Check git status in storage directory -mps git add . # Stage all changes -mps git commit -m "msg" # Commit changes -mps git auto # Auto add, commit, pull, and push -mps git autocommit # Auto add and commit only +``` +mps/version → mps/mps (defines ir()) → + mps/constants → mps/config → mps/interpolators → + mps/elements → mps/engines → mps/store → cli/mps ``` -#### Run Shell Commands +### Parser (`lib/mps/engines/mps.rb`) -```bash -mps cmd ls -la # List files in storage directory -mps cmd pwd # Show current directory -``` +A single-pass, position-based stack parser. Each iteration finds the nearest `@element[args]{` or `}` from the current position; whichever comes first wins. A stack frame carries the element sign, args, body start offset, child counter, and ref path. Closed frames become element instances, keyed by dotted ref paths (`epoch.1.2` = second child of first top-level element). -#### Version +### Elements (`lib/mps/elements/`) -```bash -mps version # Show MPS version -``` +Each type includes the `Element` mixin which provides `split_args`, `parsed_args`, `raw_args`, and `tags`. Type-specific `parse_args` class methods handle named attributes: tasks have `status`, logs have `start`/`end` (from which `duration_minutes` and `duration_str` are derived), reminders have `at`. -## File Format +### Store (`lib/mps/store.rb`) -MPS files use a simple syntax with elements defined in curly braces: +A library class that owns all filesystem work — finding files by date, creating new paths, parsing, appending, searching. The CLI delegates entirely to Store; there are no direct file operations in `lib/cli/mps.rb`. -``` -@task[tag1, tag2]{ - Complete project documentation -} +--- -@note{ - Important note about the project -} +## Before and after: what Claude changed -@reminder[at: 3pm]{ - Meeting with team -} +The pre-Claude baseline (commit `66ac095`) had only five commands (`open`, `git`, `autogit`, `cmd`, `version`) and a fragile engine. Here is what changed and why. -@log[start: 09:00, end: 12:30]{ - Working on feature implementation -} +### Parser -@mps{ - @task{ - Nested task inside MPS - } -} -``` +| Before | After | +|--------|-------| +| Flip-flop `at_first` boolean; double `scan_until` per loop | Position-based stack: `Regexp#match(str, pos)` picks nearer of open/close each iteration | +| `AT_REGEXP` required brackets — `@task{ }` was invisible | Brackets made optional: `(?:\[(?[^\]]*)\])?` | +| Partial sign match `/task/` could match `@taskboard` | Exact match `/\Atask\z/` | +| `eval("::MPS::Elements::#{k}")` for class lookup | `Elements.const_get(k)` — no code evaluation | +| `instance_eval("attr_accessor :disp_str")` on Unknown | `Unknown = Struct.new(:ecn, :args, :refs, :body_str)` | +| `rescue Exception` swallowed signals | `rescue StandardError` throughout | -### Element Types +### Element types -| Element | Syntax | Description | -|---------|--------|-------------| -| Task | `@task[tags]{description}` | A to-do item | -| Note | `@note{content}` | A free-form note | -| Reminder | `@reminder[at: time]{description}` | Time-based reminder | -| Log | `@log[start: time, end: time]{description}` | Time logging | -| MPS | `@mps{...}` | Nested MPS block | +| Before | After | +|--------|-------| +| No argument parsing — args string was opaque | `split_args` parses `"work, status: done"` → tags + attrs hash | +| No status, duration, or time accessors | `done?`, `open?`, `duration_str`, `duration_minutes`, `at` | +| `# frozen string_literal: true` (space typo, magic comment inactive) | `# frozen_string_literal: true` | -## Configuration +### Store layer + +Before: CLI methods did their own `Dir.glob`, `File.read`, and `File.write`. No shared abstraction. + +After: `MPS::Store` owns all file operations. One place to fix bugs; CLI is thin orchestration only. -On first run, MPS creates configuration files in your home directory: +A specific fix during Store development: `Dir.glob().grep(MPS_FILE_NAME_REGEXP)` matched against full paths (e.g. `/home/you/.mps/mps/20260428.mps`) while the regexp was anchored to basenames. Changed to `.select { |f| File.basename(f) =~ regexp }`. -- `~/.mps_config.yaml` - Main configuration file -- `~/.mps/` - MPS data directory -- `~/.mps/mps/` - Storage directory for `.mps` files -- `~/.mps/mps.log` - Log file +### CLI -Default storage location: `~/.mps/mps/` +| Before | After | +|--------|-------| +| `open`, `git`, `autogit`, `cmd`, `version` | + `list`, `append`, `search`, `stats`, `export` | +| `git pull orign master` typo | `git pull #{git_remote} #{git_branch}` (configurable) | +| `git_remote`/`git_branch` hardcoded | Read from config YAML | +| No output formatting | Colorized type badges, status/duration/time extras, nested tree | ## Requirements - Ruby >= 2.3.0 -- Vim (for editing) -- Git (for version control features) +- Vim (for `open` / default command) +- Git (for `git` / `autogit`) ## Dependencies -- `strscan` - String scanning -- `thor` - CLI framework -- `tty-editor` - Terminal editor integration -- `chronic` - Date/time parsing -- `cli-ui` - User interface +- `thor` — CLI framework +- `tty-editor` — editor integration +- `chronic` — natural-language date parsing +- `cli-ui` — terminal UI (multi-file prompt) +- `strscan` — string scanning in parser ## Contributing -Bug reports and pull requests are welcome on GitHub at https://github.com/mash-97/mps. +Bug reports and pull requests are welcome at https://github.com/mash-97/mps. ## License -The gem is available as open source under the terms of the MIT License. +MIT License. diff --git a/lib/cli/mps.rb b/lib/cli/mps.rb index 45735fb..f97451e 100644 --- a/lib/cli/mps.rb +++ b/lib/cli/mps.rb @@ -2,139 +2,440 @@ require 'thor' require 'yaml' +require 'json' +require 'csv' require 'cli/ui' module MPS module CLI class MPS < Thor include Thor::Actions - class_option :verbose, type: :boolean, default: false - class_option :config_path, type: :string, default: ::MPS::Constants::MPS_CONFIG_FILE, desc: "mps config file path" - class_option :force, type: :boolean, default: false + class_option :verbose, type: :boolean, default: false + class_option :config_path, type: :string, default: ::MPS::Constants::MPS_CONFIG_FILE, + desc: "mps config file path" + class_option :force, type: :boolean, default: false default_task :open + VALID_TYPES = %w[task note log reminder].freeze + def self.exit_on_failure? true end - desc "version", "print version" + # ── version ──────────────────────────────────────────────────────────── + + desc "version", "Print version" def version say "mps (v#{::MPS::VERSION})" end - desc "open DATESIGN", "Open mps file in editor, usually in Vim" - def open(datesign="today") - init() + # ── open ─────────────────────────────────────────────────────────────── + + desc "open [DATESIGN]", "Open .mps file in editor (default: today)" + def open(datesign = "today") + init begin - date = ::MPS.get_date(datesign) - file_name = nil - inside(@config.storage_dir) do - entries = Dir["**/#{date.strftime('%Y%m%d')}*\.#{::MPS::Constants::MPS_EXT}"].grep(::MPS::Constants::MPS_FILE_NAME_REGEXP) - if entries.length == 0 - file_name = ::MPS::Constants::MPS_NEW_FILE_NAME_GEN.call(date) - elsif entries.length == 1 - file_name = entries.first - else - file_name = ::CLI::UI::Prompt.ask("#{entries.size} files found: ") do |handler| - entries.each do |entry| - handler.option(entry){|s|s} - end - end + date = ::MPS.get_date(datesign) + store = ::MPS::Store.new(@config.storage_dir) + files = store.find_files(date) + file_path = if files.size > 1 + ::CLI::UI::Prompt.ask("#{files.size} files found:") do |h| + files.each { |f| h.option(File.basename(f)) { |_| f } } end - @config.logger.info("Open MPS in text editor\n") - written_bytes = ::MPS.open_editor(file_name) - @config.logger.info("Done written Size: #{written_bytes} bytes\n") - say_status :written, "#{written_bytes} bytes", :green + else + store.find_or_create_path(date) end + @config.logger.info("Open MPS in text editor\n") + written = ::MPS.open_editor(file_path) + @config.logger.info("Done written Size: #{written} bytes\n") + say_status :written, "#{written} bytes", :green + rescue StandardError => e + raise Thor::Error, e + end + end - rescue Exception => err_msg - raise Thor::Error, err_msg + # ── list ─────────────────────────────────────────────────────────────── + + desc "list [DATESIGN]", "List elements for a date (default: today)" + method_option :type, type: :string, aliases: "-t", + desc: "Filter by type: task, note, log, reminder" + method_option :tag, type: :string, aliases: "-g", desc: "Filter by tag" + method_option :status, type: :string, aliases: "-s", + desc: "Filter tasks by status: open, done" + method_option :since, type: :string, aliases: "-S", + desc: "Show elements from SINCE up to DATESIGN" + def list(datesign = "today") + init + begin + store = ::MPS::Store.new(@config.storage_dir) + date = ::MPS.get_date(datesign) + dates = options[:since] ? date_range(options[:since], date) : [date.to_date] + + shown = 0 + dates.each do |d| + all = store.parse_date(d) + next if all.empty? + say set_color("── #{d.strftime('%Y-%m-%d')} ─────────────", :white) if dates.size > 1 + shown += print_tree(all, options) + end + say set_color("(no elements found)", :yellow) if shown.zero? + rescue StandardError => e + raise Thor::Error, e end end - desc "git GITCOMMAND", "Run git commands inside the :storage_dir directory" - def git(*commands) - init() + # ── append ───────────────────────────────────────────────────────────── + + desc "append TYPE BODY", "Append an element to today's file without opening Vim" + method_option :tags, type: :string, desc: "Comma-separated tags (e.g. work,release)" + method_option :status, type: :string, desc: "Task status: open (default) or done" + method_option :at, type: :string, desc: "Time for reminders (e.g. '3pm')" + method_option :start_time, type: :string, desc: "Start time for logs (HH:MM)" + method_option :end_time, type: :string, desc: "End time for logs (HH:MM)" + def append(type, *body_parts) + init + begin + type = type.downcase + unless VALID_TYPES.include?(type) + raise Thor::Error, "Unknown type '#{type}'. Valid: #{VALID_TYPES.join(', ')}" + end + body = body_parts.join(" ") + tags = options[:tags]&.split(",")&.map(&:strip) || [] + attrs = {} + attrs[:status] = options[:status] if options[:status] + attrs[:at] = options[:at] if options[:at] + attrs[:start] = options[:start_time] if options[:start_time] + attrs[:end] = options[:end_time] if options[:end_time] + + store = ::MPS::Store.new(@config.storage_dir) + path = store.append(type: type, body: body, tags: tags, attrs: attrs) + say_status :appended, "#{type_badge(type)} #{body}", :green + @config.logger.info("Appended #{type} to #{File.basename(path)}\n") + rescue StandardError => e + raise Thor::Error, e + end + end + + # ── search ───────────────────────────────────────────────────────────── + + desc "search QUERY", "Full-text search across all .mps files" + method_option :type, type: :string, aliases: "-t", desc: "Filter by type" + method_option :tag, type: :string, aliases: "-g", desc: "Filter by tag" + method_option :since, type: :string, aliases: "-S", desc: "Search from this date onward" + def search(query) + init begin - git_command = "git status" - if commands.first=="auto" - git_command = "git add . && git commit -m \"$(date)\" && git pull orign master && git push origin master" - elsif commands.first=="autocommit" - git_command = "git add . && git commit -m \"$(date)\"" - elsif commands.size>0 - commands = commands.each.collect{|c| c.include?(' ') ? "\"#{c}\"" : c } - git_command = "git #{commands.join(' ')}" + store = ::MPS::Store.new(@config.storage_dir) + since_date = options[:since] ? ::MPS.get_date(options[:since]).to_date : nil + results = store.search( + query, + type_filter: options[:type]&.downcase, + tag_filter: options[:tag], + since_date: since_date + ) + if results.empty? + say set_color("No results for '#{query}'", :yellow) + return end - inside @config.storage_dir do - run git_command + results.each do |r| + el = r[:element] + tags_str = el.tags.empty? ? "" : " #{set_color("[#{el.tags.join(', ')}]", :white)}" + body_line = el.body_str.strip.lines.first&.strip + say "#{set_color(r[:date_str], :white)} #{type_badge(el.class::SIGNATURE_STAMP)} " \ + "#{element_extra(el)}#{body_line}#{tags_str}" + end + say set_color("(#{results.size} result#{results.size == 1 ? '' : 's'})", :white) + rescue StandardError => e + raise Thor::Error, e + end + end + + # ── stats ────────────────────────────────────────────────────────────── + + desc "stats [DATESIGN]", "Show element counts and log durations" + method_option :since, type: :string, aliases: "-S", + desc: "Stats from SINCE up to DATESIGN" + def stats(datesign = "today") + init + begin + store = ::MPS::Store.new(@config.storage_dir) + date = ::MPS.get_date(datesign) + dates = options[:since] ? date_range(options[:since], date) : [date.to_date] + + total = Hash.new(0) + total_log_mins = 0 + any = false + + dates.each do |d| + elements = store.parse_date(d).values + .reject { |e| e.is_a?(::MPS::Elements::MPS) } + next if elements.empty? + any = true + counts = elements.group_by { |e| e.class::SIGNATURE_STAMP }.transform_values(&:size) + log_mins = elements.select { |e| e.is_a?(::MPS::Elements::Log) } + .sum { |e| e.duration_minutes || 0 } + tasks = elements.select { |e| e.is_a?(::MPS::Elements::Task) } + + parts = [] + if (n = counts["task"]) + open_n = tasks.count(&:open?) + done_n = tasks.count(&:done?) + parts << "#{n} task#{n != 1 ? 's' : ''} " \ + "(#{set_color("#{open_n} open", :yellow)}, " \ + "#{set_color("#{done_n} done", :green)})" + end + parts << "#{counts['note']} note#{counts['note'] != 1 ? 's' : ''}" if counts["note"] + parts << "#{counts['reminder']} reminder#{counts['reminder'] != 1 ? 's':''}" if counts["reminder"] + if (n = counts["log"]) + h, m = log_mins.divmod(60) + dur = log_mins > 0 ? " (#{h}h#{m > 0 ? "#{m}m" : ""})" : "" + parts << "#{n} log#{n != 1 ? 's' : ''}#{dur}" + end + + say "#{set_color(d.strftime('%Y-%m-%d'), :white)} — #{parts.join(', ')}" + counts.each { |k, v| total[k] += v } + total_log_mins += log_mins end - rescue Exception => err_msg - raise Thor::Error, err_msg + say set_color("(no data found)", :yellow) unless any + + if dates.size > 1 && any + say set_color("─" * 44, :white) + tparts = [] + tparts << "#{total['task']} tasks" if total["task"] > 0 + tparts << "#{total['note']} notes" if total["note"] > 0 + tparts << "#{total['reminder']} reminders" if total["reminder"] > 0 + if total["log"] > 0 + h, m = total_log_mins.divmod(60) + dur = total_log_mins > 0 ? " (#{h}h#{m > 0 ? "#{m}m" : ""} total)" : "" + tparts << "#{total['log']} logs#{dur}" + end + say "Total: #{tparts.join(', ')}" + end + rescue StandardError => e + raise Thor::Error, e end end - desc "autogit", "Auto stage, commit, pull and push" - def autogit() - init() + # ── export ───────────────────────────────────────────────────────────── + + desc "export [DATESIGN]", "Export elements to JSON or CSV (writes to stdout)" + method_option :format, type: :string, default: "json", aliases: "-f", + desc: "Output format: json, csv" + method_option :type, type: :string, aliases: "-t", desc: "Filter by type" + method_option :since, type: :string, aliases: "-S", + desc: "Export from SINCE up to DATESIGN" + def export(datesign = "today") + init begin - git_command = "git add . && git commit -m \"$(date)\" && git pull origin master && git push origin master" - inside @config.storage_dir do - run git_command + store = ::MPS::Store.new(@config.storage_dir) + date = ::MPS.get_date(datesign) + dates = options[:since] ? date_range(options[:since], date) : [date.to_date] + fmt = options[:format].downcase + + unless %w[json csv].include?(fmt) + raise Thor::Error, "Unknown format '#{fmt}'. Use: json, csv" + end + + records = [] + dates.each do |d| + store.parse_date(d).each do |ref, el| + next if el.is_a?(::MPS::Elements::MPS) + next if options[:type] && el.class::SIGNATURE_STAMP != options[:type].downcase + extra = el.parsed_args.reject { |k, _| k == :tags } + records << { + date: d.strftime("%Y-%m-%d"), + ref: ref, + type: el.class::SIGNATURE_STAMP, + tags: el.tags.join(","), + body: el.body_str.strip + }.merge(extra) + end + end + + if fmt == "json" + say JSON.pretty_generate(records) + else + keys = %i[date ref type tags body] + + (records.flat_map(&:keys) - %i[date ref type tags body]).uniq + say CSV.generate { |csv| + csv << keys.map(&:to_s) + records.each { |r| csv << keys.map { |k| r[k] } } + } end - rescue Exception => err_msg - raise Thor::Error, err_msg + rescue StandardError => e + raise Thor::Error, e end end - - desc "cmd COMMAND", "Run shell commands inside the :storage_dir directory" - def cmd(*commands) - init() + + # ── git / autogit / cmd ──────────────────────────────────────────────── + + desc "git GITCOMMAND", "Run git commands inside storage_dir" + def git(*commands) + init begin - commands = commands.each.collect{|c| c.include?(' ') ? "\"#{c}\"" : c } - shell_command = "#{commands.join(' ')}" - inside @config.storage_dir do - run shell_command + git_command = if commands.first == "auto" + "git add . && git commit -m \"$(date)\" && " \ + "git pull #{@config.git_remote} #{@config.git_branch} && " \ + "git push #{@config.git_remote} #{@config.git_branch}" + elsif commands.first == "autocommit" + "git add . && git commit -m \"$(date)\"" + elsif commands.size > 0 + cmds = commands.map { |c| c.include?(" ") ? "\"#{c}\"" : c } + "git #{cmds.join(' ')}" + else + "git status" end + inside(@config.storage_dir) { run git_command } + rescue StandardError => e + raise Thor::Error, e + end + end - rescue Exception => err_msg - raise Thor::Error, err_msg + desc "autogit", "Auto stage, commit, pull and push" + def autogit + init + begin + cmd = "git add . && git commit -m \"$(date)\" && " \ + "git pull #{@config.git_remote} #{@config.git_branch} && " \ + "git push #{@config.git_remote} #{@config.git_branch}" + inside(@config.storage_dir) { run cmd } + rescue StandardError => e + raise Thor::Error, e end end - private - def init() + desc "cmd COMMAND", "Run shell commands inside storage_dir" + def cmd(*commands) + init begin - @config = load_config(options[:config_path], force: options[:force]) - rescue Exception => err_msg - say_status "error", "failed to initialize" - raise Thor::Error, err_msg + cmds = commands.map { |c| c.include?(" ") ? "\"#{c}\"" : c } + inside(@config.storage_dir) { run cmds.join(" ") } + rescue StandardError => e + raise Thor::Error, e end end + private + + # ── config helpers ───────────────────────────────────────────────────── + + def init + @config = load_config(options[:config_path], force: options[:force]) + rescue StandardError => e + say_status "error", "failed to initialize" + raise Thor::Error, e + end + def load_config(config_path, force: false) - if File.exist?(config_path) and not force - return ::MPS::Config.new(**load_tangible_config_hash(config_path)) - end - ::MPS::Config.init(config_path) - return ::MPS::Config.new(**load_tangible_config_hash(config_path)) + ::MPS::Config.init(config_path) if !File.exist?(config_path) || force + ::MPS::Config.new(**load_tangible_config_hash(config_path)) end def load_tangible_config_hash(config_path) conf_hash = ::MPS::Config.load_conf_hash(config_path) - if not Dir.exist?(conf_hash[:storage_dir]) - say_status "mps storage directory", "not found: #{conf_hash[:storage_dir]}", :yellow - empty_directory conf_hash[:storage_dir] + empty_directory conf_hash[:storage_dir] unless Dir.exist?(conf_hash[:storage_dir]) + empty_directory conf_hash[:mps_dir] unless Dir.exist?(conf_hash[:mps_dir]) + create_file conf_hash[:log_file] unless File.exist?(conf_hash[:log_file]) + conf_hash + end + + # ── display helpers ──────────────────────────────────────────────────── + + TYPE_COLORS = { + "task" => :green, + "note" => :cyan, + "reminder" => :magenta, + "log" => :yellow + }.freeze + + def type_badge(type_name) + color = TYPE_COLORS.fetch(type_name, :white) + set_color("[#{type_name}]", color) + end + + def element_extra(el) + case el + when ::MPS::Elements::Task + status = el.parsed_args[:status] || "open" + color = status == "done" ? :green : :yellow + "(#{set_color(status, color)}) " + when ::MPS::Elements::Log + dur = el.duration_str + dur ? "(#{set_color(dur, :yellow)}) " : "" + when ::MPS::Elements::Reminder + at = el.parsed_args[:at] + at ? "(#{set_color(at, :magenta)}) " : "" + else + "" end - if not Dir.exist?(conf_hash[:mps_dir]) - say_status "mps directory", "not found #{conf_hash[:mps_dir]}", :yellow - empty_directory(conf_hash[:mps_dir]) + end + + def print_element(el, depth: 0) + indent = " " * (depth + 1) + type_name = el.class::SIGNATURE_STAMP + tags_str = el.tags.empty? ? "" : " #{set_color("[#{el.tags.join(', ')}]", :white)}" + body_line = el.body_str.strip.lines.first&.strip + say "#{indent}#{type_badge(type_name)} #{element_extra(el)}#{body_line}#{tags_str}" + end + + # Renders elements_hash as an indented tree ordered by ref path. + # @mps containers show as group headers; the synthetic root wrapper is skipped. + # Returns the count of non-MPS elements actually printed. + def print_tree(elements_hash, opts) + sorted = elements_hash.sort_by { |k, _| k.split(".").map(&:to_i) } + return 0 if sorted.empty? + + root_segs = sorted.first.first.split(".").size # always 1 (just the epoch) + shown = 0 + + sorted.each do |ref_key, el| + depth = ref_key.split(".").size - root_segs - 1 + next if depth < 0 # root synthetic @mps wrapper + + if el.is_a?(::MPS::Elements::MPS) + # Only show @mps group header when it has at least one visible child. + prefix = "#{ref_key}." + any_visible = elements_hash.any? do |k, v| + k.start_with?(prefix) && !v.is_a?(::MPS::Elements::MPS) && visible?(v, opts) + end + next unless any_visible + indent = " " * (depth + 1) + say "#{indent}#{set_color("[@mps]", :white)}" + else + next unless visible?(el, opts) + print_element(el, depth: depth) + shown += 1 + end end - if not File.exist?(conf_hash[:log_file]) - say_status "log file", "not found #{conf_hash[:log_file]}", :yellow - create_file conf_hash[:log_file] + shown + end + + def visible?(el, opts) + type_f = opts[:type]&.downcase + tag_f = opts[:tag] + status_f = opts[:status] + return false if type_f && el.class::SIGNATURE_STAMP != type_f + return false if tag_f && !el.tags.include?(tag_f) + # --status only matches elements that carry a status field (i.e. tasks) + if status_f + s = el.respond_to?(:parsed_args) ? el.parsed_args[:status] : nil + return false if s.nil? || s != status_f end - return conf_hash + true + end + + # ── filtering (used by search/stats helpers) ──────────────────────────── + + def filtered_elements(elements_hash, opts) + elements_hash.values + .reject { |e| e.is_a?(::MPS::Elements::MPS) } + .select { |e| visible?(e, opts) } + end + + def date_range(since_str, to_date) + since_date = ::MPS.get_date(since_str).to_date + (since_date..to_date.to_date).to_a end end end diff --git a/lib/mps.rb b/lib/mps.rb index bedeed0..f4ed508 100644 --- a/lib/mps.rb +++ b/lib/mps.rb @@ -13,6 +13,7 @@ ir "mps/interpolators/interpolators" ir "mps/elements/elements" ir "mps/engines/engines" +ir "mps/store" ir "cli/mps" module MPS class Error < StandardError; end diff --git a/lib/mps/config.rb b/lib/mps/config.rb index 509f5ac..53afe51 100644 --- a/lib/mps/config.rb +++ b/lib/mps/config.rb @@ -15,10 +15,14 @@ class MPSStorageDirectoryNotFound < StandardError;end; attr_reader :storage_dir attr_reader :logger attr_reader :log_file + attr_reader :git_remote + attr_reader :git_branch def initialize(**conf_hash) @mps_dir = conf_hash[:mps_dir] @storage_dir = conf_hash[:storage_dir] @log_file = conf_hash[:log_file] + @git_remote = conf_hash.fetch(:git_remote, "origin") + @git_branch = conf_hash.fetch(:git_branch, "master") @logger = Logger.new(File.open(@log_file, "a+")) @logger.formatter = proc do |sev, time, pn, msg| time = time.strftime("[%Y-%m-%d %H:%M:%S]") diff --git a/lib/mps/constants.rb b/lib/mps/constants.rb index 422260e..6a4509a 100644 --- a/lib/mps/constants.rb +++ b/lib/mps/constants.rb @@ -24,8 +24,8 @@ module Constants # clip the mps filename except the extention, usually datestamp MPS_FILE_NAME_CLIPPER = ->(file_basename){ - MPS_FILE_NAME_REGEXP=~file_basename - $~[1] + m = MPS_FILE_NAME_REGEXP.match(file_basename) + m ? m[1] : "0" } # clip datestamp with hash accessibility from the mps filename @@ -50,21 +50,19 @@ module Constants DEFAULT_CONF_HASH = { mps_dir: MPS_DIR, storage_dir: MPS_STORAGE_DIR, - log_file: MPS_LOG_FILE + log_file: MPS_LOG_FILE, + git_remote: "origin", + git_branch: "master" } - # at or @[]{} signature regexps - # at regexp with ignore group to have the - # strscan pointer at the begining of the at signature - AT_REGEXP_LA = /(?=@[a-zA-Z0-9]+?\[[\s\S]*?\]\s*?\{)/ - # at regexp without ingnoring groups - AT_REGEXP = /@(?[a-zA-Z0-9_,:\s]+?)\[(?.*?)\]\s*?\{/ + # at or @[]{} signature regexps — brackets are optional: @task{ } and @task[]{ } both valid + AT_REGEXP_LA = /(?=@[a-zA-Z0-9_]+(?:\[[\s\S]*?\])?\s*\{)/ + AT_REGEXP = /@(?[a-zA-Z0-9_]+)(?:\[(?[^\]]*)\])?\s*\{/ - # end curly bracket regexp - # ignore group + # end curly bracket regexp — excludes } surrounded by single-quotes END_CURLY_REGEXP_LA = /(?=(? 0 + h, m = mins.divmod(60) + m > 0 ? "#{h}h#{m}m" : "#{h}h" + end end end -end \ No newline at end of file +end diff --git a/lib/mps/elements/mps.rb b/lib/mps/elements/mps.rb index 8857297..4ee0685 100644 --- a/lib/mps/elements/mps.rb +++ b/lib/mps/elements/mps.rb @@ -1,11 +1,15 @@ -# frozen string_literal: true +# frozen_string_literal: true module MPS module Elements class MPS SIGNATURE_STAMP = "mps" - SIGNATURE_REGEX = /mps/ + SIGNATURE_REGEX = /\Amps\z/ include Element + + def self.parse_args(raw) + { tags: Element.split_args(raw)[:tags] } + end end end -end \ No newline at end of file +end diff --git a/lib/mps/elements/note.rb b/lib/mps/elements/note.rb index 82e802f..d03705d 100644 --- a/lib/mps/elements/note.rb +++ b/lib/mps/elements/note.rb @@ -1,11 +1,15 @@ -# frozen string_literal: true +# frozen_string_literal: true module MPS module Elements class Note SIGNATURE_STAMP = "note" - SIGNATURE_REGEX = /note/ + SIGNATURE_REGEX = /\Anote\z/ include Element + + def self.parse_args(raw) + { tags: Element.split_args(raw)[:tags] } + end end end -end \ No newline at end of file +end diff --git a/lib/mps/elements/reminder.rb b/lib/mps/elements/reminder.rb index b02ed70..4638367 100644 --- a/lib/mps/elements/reminder.rb +++ b/lib/mps/elements/reminder.rb @@ -1,11 +1,16 @@ -# frozen string_literal: true +# frozen_string_literal: true module MPS module Elements class Reminder SIGNATURE_STAMP = "reminder" - SIGNATURE_REGEX = /reminder/ + SIGNATURE_REGEX = /\Areminder\z/ include Element + + def self.parse_args(raw) + p = Element.split_args(raw) + { tags: p[:tags], at: p[:attrs][:at] } + end end end -end \ No newline at end of file +end diff --git a/lib/mps/elements/task.rb b/lib/mps/elements/task.rb index 468cdd5..aa9c0df 100644 --- a/lib/mps/elements/task.rb +++ b/lib/mps/elements/task.rb @@ -1,11 +1,19 @@ -# frozen string_literal: true +# frozen_string_literal: true module MPS module Elements class Task SIGNATURE_STAMP = "task" - SIGNATURE_REGEX = /task/ + SIGNATURE_REGEX = /\Atask\z/ include Element + + def self.parse_args(raw) + p = Element.split_args(raw) + { tags: p[:tags], status: p[:attrs].fetch(:status, "open") } + end + + def done? = parsed_args[:status] == "done" + def open? = !done? end end -end \ No newline at end of file +end diff --git a/lib/mps/engines/mps.rb b/lib/mps/engines/mps.rb index e879149..1624402 100644 --- a/lib/mps/engines/mps.rb +++ b/lib/mps/engines/mps.rb @@ -2,97 +2,107 @@ module MPS module Engines - class EngineError < StandardError;end; - class MPS + class EngineError < StandardError; end + + class Parser + # Holds an unknown element type (sign not in registered element_classes). + Unknown = Struct.new(:ecn, :args, :refs, :body_str) + attr_reader :logger attr_reader :element_classes attr_reader :interpolator_classes + def initialize(config) @config = config - @element_classes = ::MPS::Elements.constants.map{|k|eval("::MPS::Elements::#{k}")}.select{|x|x.class==Class} - @interpolator_classes = ::MPS::Interpolators.constants.map{|k|eval("::MPS::Interpolators::#{k}")}.select{|x|x.class==Class} + @element_classes = ::MPS::Elements.constants + .map { |k| ::MPS::Elements.const_get(k) } + .select { |x| x.class == Class } + @interpolator_classes = ::MPS::Interpolators.constants + .map { |k| ::MPS::Interpolators.const_get(k) } + .select { |x| x.class == Class } @logger = @config.logger end + # Returns the element class whose SIGNATURE_REGEX matches +str+, or nil. def self.matched_element_class(str, element_classes) - element_classes.each do |ec| - return ec if str=~ec::SIGNATURE_REGEX - end - return nil + element_classes.find { |ec| str =~ ec::SIGNATURE_REGEX } end + # Peeks ahead in +str_scanner+ for +regex_la+ without consuming input. + # Returns the position of the match, or string size if no match. def self.look_ahead_pos(str_scanner, regex_la) - pos = str_scanner.string.size - if str_scanner.scan_until(regex_la) - pos = str_scanner.pos - str_scanner.unscan - end - return pos + return str_scanner.string.size unless str_scanner.scan_until(regex_la) + pos = str_scanner.pos + str_scanner.unscan + pos end - def self.parse_mps_file_to_elments_hash(mps_file_path, element_classes) - mps_str = File.read(mps_file_path) - # add elements::mps signature - mps_str = "@#{::MPS::Elements::MPS::SIGNATURE_STAMP}[]{"+mps_str+"}" - str_scanr = StringScanner.new(mps_str) - base_ref = ::MPS::Constants::MPS_FILE_NAME_CLIPPER.call(File.basename(mps_file_path)) - refs = [base_ref.to_i] - elements_hash = {} - stack = [] - at_first = true - element = nil - - while !str_scanr.eos? - if at_first && str_scanr.scan_until(::MPS::Constants::AT_REGEXP_LA) - s_pos = str_scanr.pos - str_scanr.scan_until(::MPS::Constants::AT_REGEXP) - matched_data = str_scanr.string[s_pos..str_scanr.pos-1].match(::MPS::Constants::AT_REGEXP) - # puts("matched: #{matched_data.inspect}") - element_class = self.matched_element_class(matched_data["element_sign"], element_classes) - - element_class = matched_data["element_sign"] if element_class==nil - stack << { - element_class: element_class, - element_args: matched_data["args"], - body_start_pos: str_scanr.pos, - start_pos: s_pos - } - elsif !at_first && str_scanr.scan_until(::MPS::Constants::END_CURLY_REGEXP) && !stack.empty? - stack_top = stack.pop() - stack_top[:end_pos] = str_scanr.pos-1 - body_str = str_scanr.string[stack_top[:body_start_pos]...stack_top[:end_pos]] - # call corresponding element class to create element instance - trefs = refs.clone() - if stack_top[:element_class].class!=Class - element = Struct.new(:ecn, :args, :refs, :body_str).new( - stack_top[:element_class], - stack_top[:element_args], - trefs, - body_str - ) - element.class.instance_eval("attr_accessor :disp_str") + # Parses +mps_file_path+ into a flat hash of ref-path => element instances. + def self.parse_mps_file_to_elements_hash(mps_file_path, element_classes) + content = File.read(mps_file_path) + wrapped = "@#{::MPS::Elements::MPS::SIGNATURE_STAMP}[]{\n#{content}\n}" + base_ref = ::MPS::Constants::MPS_FILE_NAME_CLIPPER + .call(File.basename(mps_file_path)).to_i + + open_re = ::MPS::Constants::AT_REGEXP + close_re = ::MPS::Constants::END_CURLY_REGEXP + + elements = {} + stack = [] + pos = 0 + + while pos < wrapped.size + open_m = open_re.match(wrapped, pos) + close_m = close_re.match(wrapped, pos) + + break if open_m.nil? && close_m.nil? + + use_open = open_m && (close_m.nil? || open_m.begin(0) < close_m.begin(0)) + + if use_open + ref_path = if stack.empty? + [base_ref] else - element = stack_top[:element_class].new(args: stack_top[:element_args], refs: trefs, body_str: body_str) + parent = stack.last + parent[:child_counter] += 1 + parent[:ref_path] + [parent[:child_counter]] end - elements_hash[refs.join(".")] = element - refs[-1] += 1 - end - at_pos = self.look_ahead_pos(str_scanr, ::MPS::Constants::AT_REGEXP_LA) - ec_pos = self.look_ahead_pos(str_scanr, ::MPS::Constants::END_CURLY_REGEXP_LA) + stack.push( + sign: open_m[:element_sign], + args: open_m[:args], + body_start: open_m.end(0), + child_counter: 0, + ref_path: ref_path + ) + pos = open_m.end(0) + else + break if stack.empty? + + frame = stack.pop + body_str = wrapped[frame[:body_start]...close_m.begin(0)] + ref_key = frame[:ref_path].join(".") + ec = matched_element_class(frame[:sign], element_classes) - min_pos = [at_pos, ec_pos].min + elements[ref_key] = if ec + ec.new(args: frame[:args], refs: frame[:ref_path], body_str: body_str) + else + Unknown.new(frame[:sign], frame[:args], frame[:ref_path], body_str) + end - if min_pos==at_pos and at_first - refs << 1 - elsif min_pos==ec_pos and !at_first - refs.pop() + pos = close_m.end(0) end - at_first = (min_pos==at_pos) - str_scanr.pos = min_pos end - return elements_hash + + elements + end + + class << self + alias parse_mps_file_to_elments_hash parse_mps_file_to_elements_hash end end + + # Backward-compatible alias — existing code using Engines::MPS still works. + MPS = Parser end end diff --git a/lib/mps/store.rb b/lib/mps/store.rb new file mode 100644 index 0000000..14be989 --- /dev/null +++ b/lib/mps/store.rb @@ -0,0 +1,75 @@ +# frozen_string_literal: true + +module MPS + class Store + def initialize(storage_dir) + @storage_dir = storage_dir + @element_classes = Elements.constants + .map { |k| Elements.const_get(k) } + .select { |x| x.class == Class } + end + + # First .mps file found for +date+, or nil. + def find_file(date) + find_files(date).first + end + + # All .mps files matching +date+ (handles multiple files per day). + def find_files(date) + date_str = date.strftime("%Y%m%d") + Dir[File.join(@storage_dir, "#{date_str}*.#{Constants::MPS_EXT}")] + .select { |f| File.basename(f) =~ Constants::MPS_FILE_NAME_REGEXP } + .sort + end + + # Existing file for +date+, or a generated new path (file not yet created). + def find_or_create_path(date) + find_file(date) || File.join(@storage_dir, Constants::MPS_NEW_FILE_NAME_GEN.call(date)) + end + + # Parsed elements hash for +date+. Returns {} when no file exists. + def parse_date(date) + path = find_file(date) + return {} unless path + Engines::Parser.parse_mps_file_to_elements_hash(path, @element_classes) + end + + # Appends a new element to today's (or +date+'s) file. Returns the file path. + def append(type:, body:, tags: [], attrs: {}, date: Date.today) + args_parts = attrs.map { |k, v| "#{k}: #{v}" } + Array(tags) + args_str = args_parts.join(", ") + path = find_or_create_path(date) + File.open(path, "a") { |f| f.write("\n@#{type}[#{args_str}]{\n #{body}\n}\n") } + path + end + + # All .mps files in storage, sorted by filename (chronological). + def all_files + Dir[File.join(@storage_dir, "*.#{Constants::MPS_EXT}")] + .select { |f| File.basename(f) =~ Constants::MPS_FILE_NAME_REGEXP } + .sort + end + + # Files whose date-stamp is >= +since_date+. + def files_since(since_date) + since_str = since_date.strftime("%Y%m%d") + all_files.select { |f| File.basename(f).slice(0, 8) >= since_str } + end + + # Full-text search across files. Returns [{element:, file:, date_str:}]. + # +since_date+ is a Date; +type_filter+ and +tag_filter+ are strings. + def search(query, type_filter: nil, tag_filter: nil, since_date: nil) + files = since_date ? files_since(since_date) : all_files + files.flat_map do |file| + date_str = File.basename(file).slice(0, 8) + Engines::Parser.parse_mps_file_to_elements_hash(file, @element_classes) + .values + .reject { |e| e.is_a?(Elements::MPS) } + .select { |e| type_filter.nil? || e.class::SIGNATURE_STAMP == type_filter } + .select { |e| tag_filter.nil? || e.tags.include?(tag_filter) } + .select { |e| query.nil? || e.body_str.downcase.include?(query.downcase) } + .map { |e| { element: e, file: file, date_str: date_str } } + end + end + end +end diff --git a/lib/mps/version.rb b/lib/mps/version.rb index 100dcb0..9e68f3d 100644 --- a/lib/mps/version.rb +++ b/lib/mps/version.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true module MPS - VERSION = "0.4.1" + VERSION = "0.5.0" end diff --git a/mps.gemspec b/mps.gemspec index 972eb85..c29d076 100644 --- a/mps.gemspec +++ b/mps.gemspec @@ -30,7 +30,7 @@ Gem::Specification.new do |spec| # Uncomment to register a new dependency of your gem # spec.add_dependency "example-gem", "~> 1.0" - spec.add_runtime_dependency "strscan", "~> 3.1" + spec.add_runtime_dependency "strscan", ">= 3.0" spec.add_runtime_dependency "thor", "~> 1.3" spec.add_runtime_dependency "tty-editor", "~> 0.7.0" spec.add_runtime_dependency "chronic", "~> 0.10.2" @@ -39,7 +39,7 @@ Gem::Specification.new do |spec| spec.add_development_dependency "rake", "~> 13.2" spec.add_development_dependency "minitest", "~> 5.0" spec.add_development_dependency "fakefs", "~> 2.5" - spec.add_development_dependency "tmpdir", "~> 0.2.0" + spec.add_development_dependency "tmpdir", ">= 0.1.3" spec.add_development_dependency "yard", "~> 0.9.37" spec.add_development_dependency "rack", "~> 3.1" spec.add_development_dependency "webrick", "~> 1.8" diff --git a/test/assets_test.rb b/test/assets_test.rb new file mode 100644 index 0000000..dc7cae3 --- /dev/null +++ b/test/assets_test.rb @@ -0,0 +1,128 @@ +# frozen_string_literal: true + +require_relative 'test_helper' + +# Tests that exercise real-world .mps file patterns from test/assets/. +# These are the files that exposed the no-bracket parsing bug. +class AssetsTest < Minitest::Test + include MPS + + ASSETS_DIR = File.expand_path("assets", __dir__) + + NO_BRACKETS_FILE = "20260101.1000000001.mps" # bare @element{ } syntax + MIXED_FILE = "20260102.1000000002.mps" # brackets + no-brackets mix + DEEPLY_NESTED_FILE = "20260103.1000000003.mps" # 3-level nesting + + def parse_file(name) + path = File.join(ASSETS_DIR, name) + element_classes = Elements.constants + .map { |k| Elements.const_get(k) } + .select { |x| x.class == Class } + Engines::Parser.parse_mps_file_to_elements_hash(path, element_classes) + end + + def non_mps(elements) + elements.values.reject { |e| e.is_a?(Elements::MPS) } + end + + # ── no_brackets.mps ──────────────────────────────────────────────────────── + + def test_no_brackets_task_parsed + els = non_mps(parse_file(NO_BRACKETS_FILE)) + tasks = els.select { |e| e.is_a?(Elements::Task) } + assert_equal 1, tasks.size + assert_equal "a bare task with no brackets", tasks.first.body_str.strip + end + + def test_no_brackets_all_types_parsed + els = non_mps(parse_file(NO_BRACKETS_FILE)) + assert_equal 1, els.count { |e| e.is_a?(Elements::Task) } + assert_equal 1, els.count { |e| e.is_a?(Elements::Note) } + assert_equal 1, els.count { |e| e.is_a?(Elements::Reminder) } + assert_equal 1, els.count { |e| e.is_a?(Elements::Log) } + end + + # ── mixed.mps ────────────────────────────────────────────────────────────── + + def test_mixed_outer_task_with_tags + els = non_mps(parse_file(MIXED_FILE)) + outer = els.select { |e| e.is_a?(Elements::Task) } + .find { |e| e.tags.include?("x") } + refute_nil outer, "outer task with tags x,y not found" + assert_includes outer.tags, "y" + end + + def test_mixed_nested_task_inside_tagged_task + els = non_mps(parse_file(MIXED_FILE)) + tasks = els.select { |e| e.is_a?(Elements::Task) } + assert tasks.size >= 2, "expected at least 2 tasks (outer + nested)" + nested = tasks.find { |e| e.body_str.strip == "it's a nested task with no brackets" } + refute_nil nested, "nested no-bracket task not found" + end + + def test_mixed_reminder_with_at_arg + els = non_mps(parse_file(MIXED_FILE)) + reminders = els.select { |e| e.is_a?(Elements::Reminder) } + assert_equal 1, reminders.size + assert_equal "5pm", reminders.first.parsed_args[:at] + end + + def test_mixed_log_with_tags + els = non_mps(parse_file(MIXED_FILE)) + logs = els.select { |e| e.is_a?(Elements::Log) } + assert_equal 1, logs.size + assert_includes logs.first.tags, "work" + assert_includes logs.first.tags, "backend" + end + + def test_mixed_nested_task_inside_mps_block + els = non_mps(parse_file(MIXED_FILE)) + tasks = els.select { |e| e.is_a?(Elements::Task) } + inside_mps = tasks.find { |e| e.body_str.strip == "Nested task inside a sub-block" } + refute_nil inside_mps, "task nested inside @mps{} not found" + end + + def test_mixed_nested_note_inside_mps_block + els = non_mps(parse_file(MIXED_FILE)) + notes = els.select { |e| e.is_a?(Elements::Note) } + inside_mps = notes.find { |e| e.body_str.strip == "Nested note inside a sub-block" } + refute_nil inside_mps, "note nested inside @mps{} not found" + end + + def test_mixed_total_element_count + els = non_mps(parse_file(MIXED_FILE)) + # outer task, nested task (in outer), reminder, log, nested task (in mps), nested note (in mps) + assert_equal 6, els.size + end + + # ── deeply_nested.mps ────────────────────────────────────────────────────── + + def test_deeply_nested_log_inside_task + els = non_mps(parse_file(DEEPLY_NESTED_FILE)) + logs = els.select { |e| e.is_a?(Elements::Log) } + assert_equal 1, logs.size + assert_equal "nested log inside task", logs.first.body_str.strip + end + + def test_deeply_nested_note_inside_task + els = non_mps(parse_file(DEEPLY_NESTED_FILE)) + notes = els.select { |e| e.is_a?(Elements::Note) } + assert_equal 1, notes.size + assert_equal "nested note inside task", notes.first.body_str.strip + end + + def test_deeply_nested_three_levels + els = non_mps(parse_file(DEEPLY_NESTED_FILE)) + tasks = els.select { |e| e.is_a?(Elements::Task) } + deep = tasks.find { |e| e.body_str.strip == "deeply nested task" } + refute_nil deep, "3-level nested task not found" + end + + def test_deeply_nested_task_with_tags_inside_mps + els = non_mps(parse_file(DEEPLY_NESTED_FILE)) + tasks = els.select { |e| e.is_a?(Elements::Task) } + tagged = tasks.find { |e| e.tags.include?("work") } + refute_nil tagged, "task[work] inside @mps not found" + assert_equal "task inside mps block", tagged.body_str.strip + end +end diff --git a/test/config_test.rb b/test/config_test.rb index c9826c0..742d341 100644 --- a/test/config_test.rb +++ b/test/config_test.rb @@ -4,52 +4,60 @@ class ConfigTest < Minitest::Test include Constants - def setup() - Config - end - def test_default_config() - FakeFS.with_fresh do - FileUtils.mkdir_p(MPS_DIR) - # refute_path_exists MPS_CONFIG_FILE - # assert_raises(MPS::Config::ConfigFileNotFound, "#> should raise configs file not found") do - # Config.load() - # end - # # first init and then check loaded config - # refute_path_exists MPS_LOG_FILE - # conf = Config.init() - # assert_equal conf.storage_dir, DEFAULT_CONF_HASH[:storage_dir], "#> storage dir check" - # assert_equal conf.log_file, DEFAULT_CONF_HASH[:log_file], "#> log file check" - # loaded_conf = Config.load() - # assert_equal conf.storage_dir, loaded_conf.storage_dir, "#> storage dir check" - # assert_equal conf.log_file, loaded_conf.log_file, "#> log file check" + def test_default_config + FakeFS.with_fresh do + FileUtils.mkdir_p(MPS_DIR) + refute File.exist?(MPS_CONFIG_FILE) + assert_raises(Config::ConfigFileNotFound) do + Config.load_conf_hash(MPS_CONFIG_FILE) + end + Config.init(MPS_CONFIG_FILE) + assert File.exist?(MPS_CONFIG_FILE) + conf_hash = Config.load_conf_hash(MPS_CONFIG_FILE) + assert_equal DEFAULT_CONF_HASH[:storage_dir], conf_hash[:storage_dir] + assert_equal DEFAULT_CONF_HASH[:log_file], conf_hash[:log_file] + assert_equal DEFAULT_CONF_HASH[:mps_dir], conf_hash[:mps_dir] end end def test_config_load - FakeFS.with_fresh do + FakeFS.with_fresh do + FileUtils.mkdir_p(MPS_DIR) + FileUtils.mkdir_p(MPS_STORAGE_DIR) + Config.init(MPS_CONFIG_FILE) + FileUtils.touch(DEFAULT_CONF_HASH[:log_file]) + conf_hash = Config.load_conf_hash(MPS_CONFIG_FILE) + config = Config.new(**conf_hash) + assert_equal DEFAULT_CONF_HASH[:storage_dir], config.storage_dir + assert_equal DEFAULT_CONF_HASH[:log_file], config.log_file + assert_equal "origin", config.git_remote + assert_equal "master", config.git_branch + end + end + + def test_log + FakeFS.with_fresh do FileUtils.mkdir_p(MPS_DIR) + FileUtils.mkdir_p(MPS_STORAGE_DIR) + Config.init(MPS_CONFIG_FILE) + FileUtils.touch(DEFAULT_CONF_HASH[:log_file]) + config = Config.new(**Config.load_conf_hash(MPS_CONFIG_FILE)) + config.logger.info("hello world") + config.logger.close + log_content = File.read(DEFAULT_CONF_HASH[:log_file]) + assert_match(/\[\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\] I: hello world/, log_content) end end - def test_log - FakeFS.with_fresh do - FileUtils.mkdir_p MPS_DIR - # refute_path_exists MPS_LOG_FILE - # refute_path_exists MPS_CONFIG_FILE - # assert_raises Config::ConfigFileNotFound, "#> conf file not found" do - # Config.load() - # end - # conf = Config.init() - # assert_equal conf.storage_dir, DEFAULT_CONF_HASH[:storage_dir] - # assert_equal conf.log_file, DEFAULT_CONF_HASH[:log_file] - # assert_path_exists MPS_LOG_FILE - # assert_path_exists MPS_CONFIG_FILE - - # conf.logger.info("info hello world") - # assert_match(/I:[\s\S]*hello world/, File.read(MPS_LOG_FILE)) - # conf.logger.debug("debug") - # assert_match(/D:[\s\S]*debug/, File.read(MPS_LOG_FILE)) + def test_load_conf_hash_raises_on_missing_key + FakeFS.with_fresh do + FileUtils.mkdir_p(MPS_DIR) + partial_yaml = MPS_CONFIG_FILE + File.open(partial_yaml, "w") { |f| f.write(YAML.dump({ storage_dir: "/tmp/storage" })) } + assert_raises(Config::LoadError) do + Config.load_conf_hash(partial_yaml) + end end end end diff --git a/test/element_test.rb b/test/element_test.rb new file mode 100644 index 0000000..231c7d7 --- /dev/null +++ b/test/element_test.rb @@ -0,0 +1,137 @@ +# frozen_string_literal: true + +require_relative 'test_helper' + +class ElementTest < Minitest::Test + include MPS + + # ── Element.split_args ───────────────────────────────────────────────────── + + def test_split_args_empty + r = Element.split_args("") + assert_equal({}, r[:attrs]) + assert_equal([], r[:tags]) + end + + def test_split_args_nil + r = Element.split_args(nil) + assert_equal({}, r[:attrs]) + assert_equal([], r[:tags]) + end + + def test_split_args_tags_only + r = Element.split_args("work, release, personal") + assert_equal [], r[:attrs].keys + assert_equal %w[work release personal], r[:tags] + end + + def test_split_args_attrs_only + r = Element.split_args("status: done, start: 09:00") + assert_equal "done", r[:attrs][:status] + assert_equal "09:00", r[:attrs][:start] + assert_equal [], r[:tags] + end + + def test_split_args_mixed + r = Element.split_args("work, release, status: done") + assert_equal "done", r[:attrs][:status] + assert_equal %w[work release], r[:tags] + end + + # ── Task ─────────────────────────────────────────────────────────────────── + + def test_task_default_status_open + t = Elements::Task.new(args: "work", refs: [1], body_str: "do thing") + assert_equal "open", t.parsed_args[:status] + assert t.open? + refute t.done? + end + + def test_task_status_done + t = Elements::Task.new(args: "work, status: done", refs: [1], body_str: "do thing") + assert_equal "done", t.parsed_args[:status] + assert t.done? + refute t.open? + end + + def test_task_tags + t = Elements::Task.new(args: "work, release", refs: [1], body_str: "ship it") + assert_equal %w[work release], t.tags + end + + def test_task_no_args + t = Elements::Task.new(args: "", refs: [1], body_str: "bare task") + assert_equal "open", t.parsed_args[:status] + assert_equal [], t.tags + end + + # ── Note ─────────────────────────────────────────────────────────────────── + + def test_note_tags + n = Elements::Note.new(args: "ideas, personal", refs: [1], body_str: "cool idea") + assert_equal %w[ideas personal], n.tags + end + + def test_note_no_tags + n = Elements::Note.new(args: "", refs: [1], body_str: "plain note") + assert_equal [], n.tags + end + + # ── Reminder ─────────────────────────────────────────────────────────────── + + def test_reminder_at + r = Elements::Reminder.new(args: "work, at: 3pm", refs: [1], body_str: "standup") + assert_equal "3pm", r.parsed_args[:at] + assert_equal %w[work], r.tags + end + + def test_reminder_no_at + r = Elements::Reminder.new(args: "work", refs: [1], body_str: "standup") + assert_nil r.parsed_args[:at] + end + + # ── Log ──────────────────────────────────────────────────────────────────── + + def test_log_parse_args + l = Elements::Log.new(args: "work, start: 09:00, end: 12:30", refs: [1], body_str: "deep work") + assert_equal "09:00", l.parsed_args[:start] + assert_equal "12:30", l.parsed_args[:end] + assert_equal %w[work], l.tags + end + + def test_log_duration_minutes + l = Elements::Log.new(args: "start: 09:00, end: 12:30", refs: [1], body_str: "work") + assert_equal 210, l.duration_minutes + end + + def test_log_duration_str + l = Elements::Log.new(args: "start: 09:00, end: 12:30", refs: [1], body_str: "work") + assert_equal "3h30m", l.duration_str + end + + def test_log_duration_str_exact_hours + l = Elements::Log.new(args: "start: 09:00, end: 11:00", refs: [1], body_str: "work") + assert_equal "2h", l.duration_str + end + + def test_log_duration_nil_without_times + l = Elements::Log.new(args: "work", refs: [1], body_str: "work") + assert_nil l.duration_minutes + assert_nil l.duration_str + end + + # ── tags accessor via Element mixin ──────────────────────────────────────── + + def test_tags_fallback_empty_for_type_without_parse_args + # MPS element has parse_args but let's verify tags works generically + m = Elements::MPS.new(args: "personal", refs: [1], body_str: "") + assert_equal %w[personal], m.tags + end + + # ── raw_args preserved ───────────────────────────────────────────────────── + + def test_raw_args_stored + t = Elements::Task.new(args: "work, status: done", refs: [1], body_str: "x") + assert_equal "work, status: done", t.raw_args + end +end diff --git a/test/engine_test.rb b/test/engine_test.rb new file mode 100644 index 0000000..b9b7a50 --- /dev/null +++ b/test/engine_test.rb @@ -0,0 +1,114 @@ +# frozen_string_literal: true + +require_relative 'test_helper' + +class EngineTest < Minitest::Test + include Constants + + VALID_FAKE_FILENAME = "20260101.mps" + + def element_classes_fixture + ::MPS::Elements.constants + .map { |k| ::MPS::Elements.const_get(k) } + .select { |x| x.class == Class } + end + + def parse_content(content) + FakeFS.with_fresh do + FileUtils.mkdir_p("/tmp") + path = "/tmp/#{VALID_FAKE_FILENAME}" + File.write(path, content) + ::MPS::Engines::MPS.parse_mps_file_to_elements_hash(path, element_classes_fixture) + end + end + + def test_parse_empty_file + elements = parse_content("") + assert_instance_of Hash, elements + mps_elements = elements.values.select { |e| e.class == ::MPS::Elements::MPS } + assert_equal 1, mps_elements.size + end + + def test_parse_single_task + content = "@task[]{\n do the thing\n}" + elements = parse_content(content) + tasks = elements.values.select { |e| e.class == ::MPS::Elements::Task } + assert_equal 1, tasks.size + assert_equal "do the thing", tasks.first.body_str.strip + end + + def test_parse_multiple_elements + content = "@task[]{\n task one\n}\n@note[]{\n a note\n}\n@log[]{\n worked\n}" + elements = parse_content(content) + assert_equal 1, elements.values.count { |e| e.class == ::MPS::Elements::Task } + assert_equal 1, elements.values.count { |e| e.class == ::MPS::Elements::Note } + assert_equal 1, elements.values.count { |e| e.class == ::MPS::Elements::Log } + end + + def test_parse_unknown_element + content = "@foobar[]{\n unknown stuff\n}" + elements = parse_content(content) + unknown = elements.values.reject { |e| e.class == ::MPS::Elements::MPS } + assert_equal 1, unknown.size + el = unknown.first + assert_instance_of ::MPS::Engines::MPS::Unknown, el + assert_equal "foobar", el.ecn + assert_equal "unknown stuff", el.body_str.strip + assert el.respond_to?(:ecn) + end + + def test_parse_nested_mps + content = "@mps[]{\n @task[]{\n nested task\n }\n}" + elements = parse_content(content) + assert elements.size >= 3, "expected at least 3 elements (outer mps wrapper + inner mps + task), got #{elements.size}" + assert elements.values.count { |e| e.class == ::MPS::Elements::MPS } >= 2 + task_els = elements.values.select { |e| e.class == ::MPS::Elements::Task } + assert_equal 1, task_els.size + assert_equal "nested task", task_els.first.body_str.strip + end + + def test_parse_sibling_elements_have_sequential_refs + content = "@task[]{\n A\n}\n@task[]{\n B\n}\n@task[]{\n C\n}" + elements = parse_content(content) + tasks = elements.values.select { |e| e.class == ::MPS::Elements::Task } + assert_equal 3, tasks.size + task_refs = elements.select { |_, e| e.class == ::MPS::Elements::Task }.keys + assert_equal 3, task_refs.uniq.size, "sibling tasks must have distinct ref keys" + end + + def test_parse_deeply_nested + content = "@mps[]{\n @mps[]{\n @task[]{\n deep\n }\n }\n}" + elements = parse_content(content) + task_els = elements.values.select { |e| e.class == ::MPS::Elements::Task } + assert_equal 1, task_els.size + assert_equal "deep", task_els.first.body_str.strip + # ref depth should be 4 segments: epoch.1.1.1 + task_ref = elements.find { |_, e| e.class == ::MPS::Elements::Task }.first + assert_equal 4, task_ref.split(".").size, "deeply nested task should have 4-segment ref" + end + + def test_parse_args_captured + content = "@log[start: 09:00, end: 12:30]{\n worked\n}" + elements = parse_content(content) + log_els = elements.values.select { |e| e.class == ::MPS::Elements::Log } + assert_equal 1, log_els.size + assert_equal "start: 09:00, end: 12:30", log_els.first.raw_args + end + + def test_matched_element_class_known + result = ::MPS::Engines::MPS.matched_element_class("task", element_classes_fixture) + assert_equal ::MPS::Elements::Task, result + end + + def test_matched_element_class_unknown + result = ::MPS::Engines::MPS.matched_element_class("nonexistent_xyz", element_classes_fixture) + assert_nil result + end + + def test_look_ahead_pos_no_match + scanner = StringScanner.new("hello world no match here") + scanner.scan(/hello /) + pos = ::MPS::Engines::MPS.look_ahead_pos(scanner, /IMPOSSIBLE_PATTERN_XYZ/) + assert_equal scanner.string.size, pos + end +end diff --git a/test/store_test.rb b/test/store_test.rb new file mode 100644 index 0000000..c763a8f --- /dev/null +++ b/test/store_test.rb @@ -0,0 +1,226 @@ +# frozen_string_literal: true + +require_relative 'test_helper' + +class StoreTest < Minitest::Test + include MPS + + STORAGE_DIR = "/fake/mps" + DATE = Date.new(2026, 1, 1) + DATE_STR = "20260101" + EPOCH = "1234567890" + FAKE_FILE = "#{DATE_STR}.#{EPOCH}.mps" + FAKE_PATH = "#{STORAGE_DIR}/#{FAKE_FILE}" + + def store + ::MPS::Store.new(STORAGE_DIR) + end + + # ── find_file ────────────────────────────────────────────────────────────── + + def test_find_file_returns_nil_when_absent + FakeFS.with_fresh do + FileUtils.mkdir_p(STORAGE_DIR) + assert_nil store.find_file(DATE) + end + end + + def test_find_file_returns_path_when_present + FakeFS.with_fresh do + FileUtils.mkdir_p(STORAGE_DIR) + FileUtils.touch(FAKE_PATH) + assert_equal FAKE_PATH, store.find_file(DATE) + end + end + + def test_find_files_returns_all_for_date + FakeFS.with_fresh do + FileUtils.mkdir_p(STORAGE_DIR) + path1 = "#{STORAGE_DIR}/#{DATE_STR}.1000000001.mps" + path2 = "#{STORAGE_DIR}/#{DATE_STR}.1000000002.mps" + FileUtils.touch(path1) + FileUtils.touch(path2) + result = store.find_files(DATE) + assert_equal 2, result.size + assert_includes result, path1 + assert_includes result, path2 + end + end + + # ── find_or_create_path ──────────────────────────────────────────────────── + + def test_find_or_create_path_generates_new_name_when_absent + FakeFS.with_fresh do + FileUtils.mkdir_p(STORAGE_DIR) + path = store.find_or_create_path(DATE) + assert_match(/#{DATE_STR}\.\d{10,}\.mps$/, path) + refute File.exist?(path), "should not create the file, only return the path" + end + end + + def test_find_or_create_path_returns_existing + FakeFS.with_fresh do + FileUtils.mkdir_p(STORAGE_DIR) + FileUtils.touch(FAKE_PATH) + assert_equal FAKE_PATH, store.find_or_create_path(DATE) + end + end + + # ── parse_date ───────────────────────────────────────────────────────────── + + def test_parse_date_returns_empty_when_no_file + FakeFS.with_fresh do + FileUtils.mkdir_p(STORAGE_DIR) + assert_equal({}, store.parse_date(DATE)) + end + end + + def test_parse_date_returns_mps_wrapper_for_empty_file + FakeFS.with_fresh do + FileUtils.mkdir_p(STORAGE_DIR) + File.write(FAKE_PATH, "") + result = store.parse_date(DATE) + assert_instance_of Hash, result + mps_els = result.values.select { |e| e.is_a?(Elements::MPS) } + assert_equal 1, mps_els.size + end + end + + def test_parse_date_parses_task + FakeFS.with_fresh do + FileUtils.mkdir_p(STORAGE_DIR) + File.write(FAKE_PATH, "@task[work]{\n Ship it\n}\n") + result = store.parse_date(DATE) + tasks = result.values.select { |e| e.is_a?(Elements::Task) } + assert_equal 1, tasks.size + assert_equal "Ship it", tasks.first.body_str.strip + assert_equal %w[work], tasks.first.tags + end + end + + # ── append ───────────────────────────────────────────────────────────────── + + def test_append_creates_file + FakeFS.with_fresh do + FileUtils.mkdir_p(STORAGE_DIR) + path = store.append(type: "task", body: "do thing", tags: %w[work], date: DATE) + assert File.exist?(path) + end + end + + def test_append_content_is_parseable + FakeFS.with_fresh do + FileUtils.mkdir_p(STORAGE_DIR) + store.append(type: "task", body: "do thing", tags: %w[work release], date: DATE) + result = store.parse_date(DATE) + tasks = result.values.select { |e| e.is_a?(Elements::Task) } + assert_equal 1, tasks.size + assert_equal "do thing", tasks.first.body_str.strip + assert_equal %w[work release], tasks.first.tags + end + end + + def test_append_with_attrs + FakeFS.with_fresh do + FileUtils.mkdir_p(STORAGE_DIR) + store.append(type: "log", body: "deep work", + attrs: { start: "09:00", end: "12:30" }, date: DATE) + result = store.parse_date(DATE) + logs = result.values.select { |e| e.is_a?(Elements::Log) } + assert_equal 1, logs.size + assert_equal "3h30m", logs.first.duration_str + end + end + + def test_append_multiple_creates_distinct_elements + FakeFS.with_fresh do + FileUtils.mkdir_p(STORAGE_DIR) + store.append(type: "task", body: "first", date: DATE) + store.append(type: "note", body: "second", date: DATE) + result = store.parse_date(DATE) + assert_equal 1, result.values.count { |e| e.is_a?(Elements::Task) } + assert_equal 1, result.values.count { |e| e.is_a?(Elements::Note) } + end + end + + # ── all_files / files_since ──────────────────────────────────────────────── + + def test_all_files_returns_sorted_list + FakeFS.with_fresh do + FileUtils.mkdir_p(STORAGE_DIR) + p1 = "#{STORAGE_DIR}/20260101.1000000001.mps" + p2 = "#{STORAGE_DIR}/20260102.1000000002.mps" + FileUtils.touch(p2) + FileUtils.touch(p1) + assert_equal [p1, p2], store.all_files + end + end + + def test_files_since_filters_by_date + FakeFS.with_fresh do + FileUtils.mkdir_p(STORAGE_DIR) + old = "#{STORAGE_DIR}/20260101.1000000001.mps" + new1 = "#{STORAGE_DIR}/20260110.1000000002.mps" + new2 = "#{STORAGE_DIR}/20260115.1000000003.mps" + [old, new1, new2].each { |p| FileUtils.touch(p) } + result = store.files_since(Date.new(2026, 1, 10)) + assert_includes result, new1 + assert_includes result, new2 + refute_includes result, old + end + end + + # ── search ───────────────────────────────────────────────────────────────── + + def test_search_finds_by_query + FakeFS.with_fresh do + FileUtils.mkdir_p(STORAGE_DIR) + File.write(FAKE_PATH, "@task[]{\n deploy to production\n}\n@note[]{\n unrelated\n}\n") + results = store.search("deploy") + assert_equal 1, results.size + assert_equal "deploy to production", results.first[:element].body_str.strip + end + end + + def test_search_type_filter + FakeFS.with_fresh do + FileUtils.mkdir_p(STORAGE_DIR) + File.write(FAKE_PATH, "@task[]{\n deploy\n}\n@note[]{\n deploy note\n}\n") + results = store.search("deploy", type_filter: "task") + assert_equal 1, results.size + assert_instance_of Elements::Task, results.first[:element] + end + end + + def test_search_tag_filter + FakeFS.with_fresh do + FileUtils.mkdir_p(STORAGE_DIR) + File.write(FAKE_PATH, "@task[work]{\n deploy\n}\n@task[personal]{\n deploy too\n}\n") + results = store.search("deploy", tag_filter: "work") + assert_equal 1, results.size + assert_includes results.first[:element].tags, "work" + end + end + + def test_search_since_date_excludes_old_files + FakeFS.with_fresh do + FileUtils.mkdir_p(STORAGE_DIR) + old_path = "#{STORAGE_DIR}/20260101.1000000001.mps" + new_path = "#{STORAGE_DIR}/20260115.1000000002.mps" + File.write(old_path, "@task[]{\n old task\n}\n") + File.write(new_path, "@task[]{\n new task\n}\n") + results = store.search("task", since_date: Date.new(2026, 1, 10)) + assert_equal 1, results.size + assert_equal "new task", results.first[:element].body_str.strip + end + end + + def test_search_returns_date_str + FakeFS.with_fresh do + FileUtils.mkdir_p(STORAGE_DIR) + File.write(FAKE_PATH, "@task[]{\n thing\n}\n") + results = store.search("thing") + assert_equal DATE_STR, results.first[:date_str] + end + end +end