Skip to main content

memo_cli/
cli.rs

1use std::env;
2use std::path::PathBuf;
3
4use clap::{Args, 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    /// Print shell completion script
100    Completion(CompletionArgs),
101}
102
103#[derive(Debug, Args)]
104pub struct CompletionArgs {
105    #[arg(value_enum)]
106    pub shell: crate::completion::CompletionShell,
107}
108
109#[derive(Debug, clap::Args)]
110pub struct AddArgs {
111    /// Memo text
112    pub text: String,
113
114    /// Capture source label
115    #[arg(long, default_value = "cli")]
116    pub source: String,
117
118    /// Capture timestamp (RFC3339)
119    #[arg(long)]
120    pub at: Option<String>,
121}
122
123#[derive(Debug, clap::Args)]
124pub struct UpdateArgs {
125    /// Item identifier (itm_XXXXXXXX or integer id)
126    pub item_id: String,
127
128    /// Updated memo text
129    pub text: String,
130}
131
132#[derive(Debug, clap::Args)]
133pub struct DeleteArgs {
134    /// Item identifier (itm_XXXXXXXX or integer id)
135    pub item_id: String,
136
137    /// Confirm hard-delete behavior
138    #[arg(long)]
139    pub hard: bool,
140}
141
142#[derive(Debug, clap::Args)]
143pub struct ListArgs {
144    /// Max rows to return
145    #[arg(long, default_value_t = 20)]
146    pub limit: usize,
147
148    /// Row offset for paging
149    #[arg(long, default_value_t = 0)]
150    pub offset: usize,
151
152    /// Row selection mode
153    #[arg(long, value_enum, default_value_t = ItemState::All)]
154    pub state: ItemState,
155}
156
157#[derive(Debug, clap::Args)]
158pub struct SearchArgs {
159    /// Search query text
160    pub query: String,
161
162    /// Max rows to return
163    #[arg(long, default_value_t = 20)]
164    pub limit: usize,
165
166    /// Row selection mode
167    #[arg(long, value_enum, default_value_t = ItemState::All)]
168    pub state: ItemState,
169
170    /// Search fields (comma-separated): raw, derived, tags
171    #[arg(
172        long = "field",
173        value_enum,
174        value_delimiter = ',',
175        default_values_t = [SearchField::Raw, SearchField::Derived, SearchField::Tags]
176    )]
177    pub fields: Vec<SearchField>,
178
179    /// Search match mode: fts, prefix, contains
180    #[arg(long = "match", value_enum, default_value_t = SearchMatch::Fts)]
181    pub match_mode: SearchMatch,
182}
183
184#[derive(Debug, clap::Args)]
185pub struct ReportArgs {
186    /// Report period: week or month
187    pub period: ReportPeriod,
188
189    /// IANA timezone for canonical period windows
190    #[arg(long)]
191    pub tz: Option<String>,
192
193    /// Custom report start timestamp (RFC3339)
194    #[arg(long)]
195    pub from: Option<String>,
196
197    /// Custom report end timestamp (RFC3339)
198    #[arg(long)]
199    pub to: Option<String>,
200}
201
202#[derive(Debug, clap::Args)]
203pub struct FetchArgs {
204    /// Max rows to return
205    #[arg(long, default_value_t = 50)]
206    pub limit: usize,
207
208    /// Optional cursor (reserved for future pagination)
209    #[arg(long)]
210    pub cursor: Option<String>,
211
212    /// Fetch selection mode
213    #[arg(long, value_enum, default_value_t = FetchState::Pending)]
214    pub state: FetchState,
215}
216
217#[derive(Debug, clap::Args)]
218pub struct ApplyArgs {
219    /// JSON file containing apply payload
220    #[arg(long)]
221    pub input: Option<PathBuf>,
222
223    /// Read payload JSON from stdin
224    #[arg(long)]
225    pub stdin: bool,
226
227    /// Validate payload without write-back
228    #[arg(long)]
229    pub dry_run: bool,
230}
231
232impl Cli {
233    pub fn resolve_output_mode(&self) -> Result<OutputMode, AppError> {
234        if self.json && matches!(self.format, Some(OutputFormat::Text)) {
235            return Err(AppError::usage(
236                "invalid output mode: --json cannot be combined with --format text",
237            ));
238        }
239
240        if self.json || matches!(self.format, Some(OutputFormat::Json)) {
241            return Ok(OutputMode::Json);
242        }
243
244        Ok(OutputMode::Text)
245    }
246
247    pub fn command_id(&self) -> &'static str {
248        match self.command {
249            MemoCommand::Add(_) => "memo-cli add",
250            MemoCommand::Update(_) => "memo-cli update",
251            MemoCommand::Delete(_) => "memo-cli delete",
252            MemoCommand::List(_) => "memo-cli list",
253            MemoCommand::Search(_) => "memo-cli search",
254            MemoCommand::Report(_) => "memo-cli report",
255            MemoCommand::Fetch(_) => "memo-cli fetch",
256            MemoCommand::Apply(_) => "memo-cli apply",
257            MemoCommand::Completion(_) => "memo-cli completion",
258        }
259    }
260
261    pub fn schema_version(&self) -> &'static str {
262        match self.command {
263            MemoCommand::Add(_) => "memo-cli.add.v1",
264            MemoCommand::Update(_) => "memo-cli.update.v1",
265            MemoCommand::Delete(_) => "memo-cli.delete.v1",
266            MemoCommand::List(_) => "memo-cli.list.v1",
267            MemoCommand::Search(_) => "memo-cli.search.v1",
268            MemoCommand::Report(_) => "memo-cli.report.v1",
269            MemoCommand::Fetch(_) => "memo-cli.fetch.v1",
270            MemoCommand::Apply(_) => "memo-cli.apply.v1",
271            MemoCommand::Completion(_) => "memo-cli.completion.v1",
272        }
273    }
274}
275
276fn default_db_path() -> PathBuf {
277    if let Some(data_home) = env::var_os("XDG_DATA_HOME") {
278        return PathBuf::from(data_home).join("nils-cli").join("memo.db");
279    }
280
281    if let Some(home) = env::var_os("HOME") {
282        return PathBuf::from(home)
283            .join(".local")
284            .join("share")
285            .join("nils-cli")
286            .join("memo.db");
287    }
288
289    PathBuf::from("memo.db")
290}
291
292#[cfg(test)]
293pub(crate) mod tests {
294    use clap::{CommandFactory, Parser};
295
296    use super::{Cli, MemoCommand, OutputMode, SearchField, SearchMatch};
297
298    #[test]
299    fn output_mode_defaults_to_text() {
300        let cli = Cli::parse_from(["memo-cli", "list"]);
301        let mode = cli.resolve_output_mode().expect("mode should resolve");
302        assert_eq!(mode, OutputMode::Text);
303    }
304
305    #[test]
306    fn output_mode_json_flag_wins() {
307        let cli = Cli::parse_from(["memo-cli", "--json", "list"]);
308        let mode = cli.resolve_output_mode().expect("mode should resolve");
309        assert_eq!(mode, OutputMode::Json);
310    }
311
312    #[test]
313    fn output_mode_format_json_is_supported() {
314        let cli = Cli::parse_from(["memo-cli", "--format", "json", "list"]);
315        let mode = cli.resolve_output_mode().expect("mode should resolve");
316        assert_eq!(mode, OutputMode::Json);
317    }
318
319    #[test]
320    fn output_mode_rejects_conflict() {
321        let cli = Cli::parse_from(["memo-cli", "--json", "--format", "text", "list"]);
322        let err = cli.resolve_output_mode().expect_err("conflict should fail");
323        assert_eq!(err.exit_code(), 64);
324    }
325
326    #[test]
327    fn parser_exposes_expected_subcommands() {
328        let mut cmd = Cli::command();
329        let subcommands = cmd
330            .get_subcommands_mut()
331            .map(|sub| sub.get_name().to_string())
332            .collect::<Vec<_>>();
333        assert!(subcommands.contains(&"add".to_string()));
334        assert!(subcommands.contains(&"update".to_string()));
335        assert!(subcommands.contains(&"delete".to_string()));
336        assert!(subcommands.contains(&"list".to_string()));
337        assert!(subcommands.contains(&"search".to_string()));
338        assert!(subcommands.contains(&"report".to_string()));
339        assert!(subcommands.contains(&"fetch".to_string()));
340        assert!(subcommands.contains(&"apply".to_string()));
341        assert!(subcommands.contains(&"completion".to_string()));
342    }
343
344    #[test]
345    fn search_fields_supports_comma_separated_values() {
346        let cli = Cli::parse_from(["memo-cli", "search", "ssd", "--field", "raw,tags"]);
347        let MemoCommand::Search(args) = cli.command else {
348            panic!("expected search command");
349        };
350
351        assert_eq!(args.fields, vec![SearchField::Raw, SearchField::Tags]);
352    }
353
354    #[test]
355    fn search_fields_default_to_all_fields() {
356        let cli = Cli::parse_from(["memo-cli", "search", "ssd"]);
357        let MemoCommand::Search(args) = cli.command else {
358            panic!("expected search command");
359        };
360
361        assert_eq!(
362            args.fields,
363            vec![SearchField::Raw, SearchField::Derived, SearchField::Tags]
364        );
365    }
366
367    #[test]
368    fn search_match_mode_defaults_to_fts() {
369        let cli = Cli::parse_from(["memo-cli", "search", "ssd"]);
370        let MemoCommand::Search(args) = cli.command else {
371            panic!("expected search command");
372        };
373
374        assert_eq!(args.match_mode, SearchMatch::Fts);
375    }
376
377    #[test]
378    fn search_match_mode_accepts_explicit_value() {
379        let cli = Cli::parse_from(["memo-cli", "search", "ssd", "--match", "contains"]);
380        let MemoCommand::Search(args) = cli.command else {
381            panic!("expected search command");
382        };
383
384        assert_eq!(args.match_mode, SearchMatch::Contains);
385    }
386}