Skip to main content

memo_cli/
cli.rs

1use std::env;
2use std::path::PathBuf;
3
4use clap::{Parser, Subcommand, ValueEnum};
5
6use crate::errors::AppError;
7
8#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
9pub enum OutputFormat {
10    Text,
11    Json,
12}
13
14#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15pub enum OutputMode {
16    Text,
17    Json,
18}
19
20impl OutputMode {
21    pub fn is_json(self) -> bool {
22        matches!(self, Self::Json)
23    }
24}
25
26#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
27pub enum ItemState {
28    All,
29    Pending,
30    Enriched,
31}
32
33#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
34pub enum SearchField {
35    Raw,
36    Derived,
37    Tags,
38}
39
40#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
41pub enum SearchMatch {
42    Fts,
43    Prefix,
44    Contains,
45}
46
47#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
48pub enum ReportPeriod {
49    Week,
50    Month,
51}
52
53#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
54pub enum FetchState {
55    Pending,
56}
57
58#[derive(Debug, Parser)]
59#[command(
60    name = "memo-cli",
61    version,
62    about = "Capture-first memo CLI with agent enrichment"
63)]
64pub struct Cli {
65    /// SQLite file path
66    #[arg(long, global = true, value_name = "path", default_value_os_t = default_db_path())]
67    pub db: PathBuf,
68
69    /// Output JSON (shorthand for --format json)
70    #[arg(long, global = true)]
71    pub json: bool,
72
73    /// Output format
74    #[arg(long, global = true, value_enum)]
75    pub format: Option<OutputFormat>,
76
77    #[command(subcommand)]
78    pub command: MemoCommand,
79}
80
81#[derive(Debug, Subcommand)]
82pub enum MemoCommand {
83    /// Capture one raw memo entry
84    Add(AddArgs),
85    /// Update one memo entry and reset derived workflow state
86    Update(UpdateArgs),
87    /// Hard-delete one memo entry and all dependent data
88    Delete(DeleteArgs),
89    /// List memo entries in deterministic order
90    List(ListArgs),
91    /// Search memo entries with selectable match mode
92    Search(SearchArgs),
93    /// Show weekly or monthly summary report
94    Report(ReportArgs),
95    /// Fetch pending items for agent enrichment
96    Fetch(FetchArgs),
97    /// Apply enrichment payloads
98    Apply(ApplyArgs),
99}
100
101#[derive(Debug, clap::Args)]
102pub struct AddArgs {
103    /// Memo text
104    pub text: String,
105
106    /// Capture source label
107    #[arg(long, default_value = "cli")]
108    pub source: String,
109
110    /// Capture timestamp (RFC3339)
111    #[arg(long)]
112    pub at: Option<String>,
113}
114
115#[derive(Debug, clap::Args)]
116pub struct UpdateArgs {
117    /// Item identifier (itm_XXXXXXXX or integer id)
118    pub item_id: String,
119
120    /// Updated memo text
121    pub text: String,
122}
123
124#[derive(Debug, clap::Args)]
125pub struct DeleteArgs {
126    /// Item identifier (itm_XXXXXXXX or integer id)
127    pub item_id: String,
128
129    /// Confirm hard-delete behavior
130    #[arg(long)]
131    pub hard: bool,
132}
133
134#[derive(Debug, clap::Args)]
135pub struct ListArgs {
136    /// Max rows to return
137    #[arg(long, default_value_t = 20)]
138    pub limit: usize,
139
140    /// Row offset for paging
141    #[arg(long, default_value_t = 0)]
142    pub offset: usize,
143
144    /// Row selection mode
145    #[arg(long, value_enum, default_value_t = ItemState::All)]
146    pub state: ItemState,
147}
148
149#[derive(Debug, clap::Args)]
150pub struct SearchArgs {
151    /// Search query text
152    pub query: String,
153
154    /// Max rows to return
155    #[arg(long, default_value_t = 20)]
156    pub limit: usize,
157
158    /// Row selection mode
159    #[arg(long, value_enum, default_value_t = ItemState::All)]
160    pub state: ItemState,
161
162    /// Search fields (comma-separated): raw, derived, tags
163    #[arg(
164        long = "field",
165        value_enum,
166        value_delimiter = ',',
167        default_values_t = [SearchField::Raw, SearchField::Derived, SearchField::Tags]
168    )]
169    pub fields: Vec<SearchField>,
170
171    /// Search match mode: fts, prefix, contains
172    #[arg(long = "match", value_enum, default_value_t = SearchMatch::Fts)]
173    pub match_mode: SearchMatch,
174}
175
176#[derive(Debug, clap::Args)]
177pub struct ReportArgs {
178    /// Report period: week or month
179    pub period: ReportPeriod,
180
181    /// IANA timezone for canonical period windows
182    #[arg(long)]
183    pub tz: Option<String>,
184
185    /// Custom report start timestamp (RFC3339)
186    #[arg(long)]
187    pub from: Option<String>,
188
189    /// Custom report end timestamp (RFC3339)
190    #[arg(long)]
191    pub to: Option<String>,
192}
193
194#[derive(Debug, clap::Args)]
195pub struct FetchArgs {
196    /// Max rows to return
197    #[arg(long, default_value_t = 50)]
198    pub limit: usize,
199
200    /// Optional cursor (reserved for future pagination)
201    #[arg(long)]
202    pub cursor: Option<String>,
203
204    /// Fetch selection mode
205    #[arg(long, value_enum, default_value_t = FetchState::Pending)]
206    pub state: FetchState,
207}
208
209#[derive(Debug, clap::Args)]
210pub struct ApplyArgs {
211    /// JSON file containing apply payload
212    #[arg(long)]
213    pub input: Option<PathBuf>,
214
215    /// Read payload JSON from stdin
216    #[arg(long)]
217    pub stdin: bool,
218
219    /// Validate payload without write-back
220    #[arg(long)]
221    pub dry_run: bool,
222}
223
224impl Cli {
225    pub fn resolve_output_mode(&self) -> Result<OutputMode, AppError> {
226        if self.json && matches!(self.format, Some(OutputFormat::Text)) {
227            return Err(AppError::usage(
228                "invalid output mode: --json cannot be combined with --format text",
229            ));
230        }
231
232        if self.json || matches!(self.format, Some(OutputFormat::Json)) {
233            return Ok(OutputMode::Json);
234        }
235
236        Ok(OutputMode::Text)
237    }
238
239    pub fn command_id(&self) -> &'static str {
240        match self.command {
241            MemoCommand::Add(_) => "memo-cli add",
242            MemoCommand::Update(_) => "memo-cli update",
243            MemoCommand::Delete(_) => "memo-cli delete",
244            MemoCommand::List(_) => "memo-cli list",
245            MemoCommand::Search(_) => "memo-cli search",
246            MemoCommand::Report(_) => "memo-cli report",
247            MemoCommand::Fetch(_) => "memo-cli fetch",
248            MemoCommand::Apply(_) => "memo-cli apply",
249        }
250    }
251
252    pub fn schema_version(&self) -> &'static str {
253        match self.command {
254            MemoCommand::Add(_) => "memo-cli.add.v1",
255            MemoCommand::Update(_) => "memo-cli.update.v1",
256            MemoCommand::Delete(_) => "memo-cli.delete.v1",
257            MemoCommand::List(_) => "memo-cli.list.v1",
258            MemoCommand::Search(_) => "memo-cli.search.v1",
259            MemoCommand::Report(_) => "memo-cli.report.v1",
260            MemoCommand::Fetch(_) => "memo-cli.fetch.v1",
261            MemoCommand::Apply(_) => "memo-cli.apply.v1",
262        }
263    }
264}
265
266fn default_db_path() -> PathBuf {
267    if let Some(data_home) = env::var_os("XDG_DATA_HOME") {
268        return PathBuf::from(data_home).join("nils-cli").join("memo.db");
269    }
270
271    if let Some(home) = env::var_os("HOME") {
272        return PathBuf::from(home)
273            .join(".local")
274            .join("share")
275            .join("nils-cli")
276            .join("memo.db");
277    }
278
279    PathBuf::from("memo.db")
280}
281
282#[cfg(test)]
283pub(crate) mod tests {
284    use clap::{CommandFactory, Parser};
285
286    use super::{Cli, MemoCommand, OutputMode, SearchField, SearchMatch};
287
288    #[test]
289    fn output_mode_defaults_to_text() {
290        let cli = Cli::parse_from(["memo-cli", "list"]);
291        let mode = cli.resolve_output_mode().expect("mode should resolve");
292        assert_eq!(mode, OutputMode::Text);
293    }
294
295    #[test]
296    fn output_mode_json_flag_wins() {
297        let cli = Cli::parse_from(["memo-cli", "--json", "list"]);
298        let mode = cli.resolve_output_mode().expect("mode should resolve");
299        assert_eq!(mode, OutputMode::Json);
300    }
301
302    #[test]
303    fn output_mode_format_json_is_supported() {
304        let cli = Cli::parse_from(["memo-cli", "--format", "json", "list"]);
305        let mode = cli.resolve_output_mode().expect("mode should resolve");
306        assert_eq!(mode, OutputMode::Json);
307    }
308
309    #[test]
310    fn output_mode_rejects_conflict() {
311        let cli = Cli::parse_from(["memo-cli", "--json", "--format", "text", "list"]);
312        let err = cli.resolve_output_mode().expect_err("conflict should fail");
313        assert_eq!(err.exit_code(), 64);
314    }
315
316    #[test]
317    fn parser_exposes_expected_subcommands() {
318        let mut cmd = Cli::command();
319        let subcommands = cmd
320            .get_subcommands_mut()
321            .map(|sub| sub.get_name().to_string())
322            .collect::<Vec<_>>();
323        assert!(subcommands.contains(&"add".to_string()));
324        assert!(subcommands.contains(&"update".to_string()));
325        assert!(subcommands.contains(&"delete".to_string()));
326        assert!(subcommands.contains(&"list".to_string()));
327        assert!(subcommands.contains(&"search".to_string()));
328        assert!(subcommands.contains(&"report".to_string()));
329        assert!(subcommands.contains(&"fetch".to_string()));
330        assert!(subcommands.contains(&"apply".to_string()));
331    }
332
333    #[test]
334    fn search_fields_supports_comma_separated_values() {
335        let cli = Cli::parse_from(["memo-cli", "search", "ssd", "--field", "raw,tags"]);
336        let MemoCommand::Search(args) = cli.command else {
337            panic!("expected search command");
338        };
339
340        assert_eq!(args.fields, vec![SearchField::Raw, SearchField::Tags]);
341    }
342
343    #[test]
344    fn search_fields_default_to_all_fields() {
345        let cli = Cli::parse_from(["memo-cli", "search", "ssd"]);
346        let MemoCommand::Search(args) = cli.command else {
347            panic!("expected search command");
348        };
349
350        assert_eq!(
351            args.fields,
352            vec![SearchField::Raw, SearchField::Derived, SearchField::Tags]
353        );
354    }
355
356    #[test]
357    fn search_match_mode_defaults_to_fts() {
358        let cli = Cli::parse_from(["memo-cli", "search", "ssd"]);
359        let MemoCommand::Search(args) = cli.command else {
360            panic!("expected search command");
361        };
362
363        assert_eq!(args.match_mode, SearchMatch::Fts);
364    }
365
366    #[test]
367    fn search_match_mode_accepts_explicit_value() {
368        let cli = Cli::parse_from(["memo-cli", "search", "ssd", "--match", "contains"]);
369        let MemoCommand::Search(args) = cli.command else {
370            panic!("expected search command");
371        };
372
373        assert_eq!(args.match_mode, SearchMatch::Contains);
374    }
375}