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 ReportPeriod {
35    Week,
36    Month,
37}
38
39#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
40pub enum FetchState {
41    Pending,
42}
43
44#[derive(Debug, Parser)]
45#[command(
46    name = "memo-cli",
47    version,
48    about = "Capture-first memo CLI with agent enrichment"
49)]
50pub struct Cli {
51    /// SQLite file path
52    #[arg(long, global = true, value_name = "path", default_value_os_t = default_db_path())]
53    pub db: PathBuf,
54
55    /// Output JSON (shorthand for --format json)
56    #[arg(long, global = true)]
57    pub json: bool,
58
59    /// Output format
60    #[arg(long, global = true, value_enum)]
61    pub format: Option<OutputFormat>,
62
63    #[command(subcommand)]
64    pub command: MemoCommand,
65}
66
67#[derive(Debug, Subcommand)]
68pub enum MemoCommand {
69    /// Capture one raw memo entry
70    Add(AddArgs),
71    /// Update one memo entry and reset derived workflow state
72    Update(UpdateArgs),
73    /// Hard-delete one memo entry and all dependent data
74    Delete(DeleteArgs),
75    /// List memo entries in deterministic order
76    List(ListArgs),
77    /// Search memo entries with FTS-backed query
78    Search(SearchArgs),
79    /// Show weekly or monthly summary report
80    Report(ReportArgs),
81    /// Fetch pending items for agent enrichment
82    Fetch(FetchArgs),
83    /// Apply enrichment payloads
84    Apply(ApplyArgs),
85}
86
87#[derive(Debug, clap::Args)]
88pub struct AddArgs {
89    /// Memo text
90    pub text: String,
91
92    /// Capture source label
93    #[arg(long, default_value = "cli")]
94    pub source: String,
95
96    /// Capture timestamp (RFC3339)
97    #[arg(long)]
98    pub at: Option<String>,
99}
100
101#[derive(Debug, clap::Args)]
102pub struct UpdateArgs {
103    /// Item identifier (itm_XXXXXXXX or integer id)
104    pub item_id: String,
105
106    /// Updated memo text
107    pub text: String,
108}
109
110#[derive(Debug, clap::Args)]
111pub struct DeleteArgs {
112    /// Item identifier (itm_XXXXXXXX or integer id)
113    pub item_id: String,
114
115    /// Confirm hard-delete behavior
116    #[arg(long)]
117    pub hard: bool,
118}
119
120#[derive(Debug, clap::Args)]
121pub struct ListArgs {
122    /// Max rows to return
123    #[arg(long, default_value_t = 20)]
124    pub limit: usize,
125
126    /// Row offset for paging
127    #[arg(long, default_value_t = 0)]
128    pub offset: usize,
129
130    /// Row selection mode
131    #[arg(long, value_enum, default_value_t = ItemState::All)]
132    pub state: ItemState,
133}
134
135#[derive(Debug, clap::Args)]
136pub struct SearchArgs {
137    /// Search query text (FTS syntax)
138    pub query: String,
139
140    /// Max rows to return
141    #[arg(long, default_value_t = 20)]
142    pub limit: 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 ReportArgs {
151    /// Report period: week or month
152    pub period: ReportPeriod,
153
154    /// IANA timezone for canonical period windows
155    #[arg(long)]
156    pub tz: Option<String>,
157
158    /// Custom report start timestamp (RFC3339)
159    #[arg(long)]
160    pub from: Option<String>,
161
162    /// Custom report end timestamp (RFC3339)
163    #[arg(long)]
164    pub to: Option<String>,
165}
166
167#[derive(Debug, clap::Args)]
168pub struct FetchArgs {
169    /// Max rows to return
170    #[arg(long, default_value_t = 50)]
171    pub limit: usize,
172
173    /// Optional cursor (reserved for future pagination)
174    #[arg(long)]
175    pub cursor: Option<String>,
176
177    /// Fetch selection mode
178    #[arg(long, value_enum, default_value_t = FetchState::Pending)]
179    pub state: FetchState,
180}
181
182#[derive(Debug, clap::Args)]
183pub struct ApplyArgs {
184    /// JSON file containing apply payload
185    #[arg(long)]
186    pub input: Option<PathBuf>,
187
188    /// Read payload JSON from stdin
189    #[arg(long)]
190    pub stdin: bool,
191
192    /// Validate payload without write-back
193    #[arg(long)]
194    pub dry_run: bool,
195}
196
197impl Cli {
198    pub fn resolve_output_mode(&self) -> Result<OutputMode, AppError> {
199        if self.json && matches!(self.format, Some(OutputFormat::Text)) {
200            return Err(AppError::usage(
201                "invalid output mode: --json cannot be combined with --format text",
202            ));
203        }
204
205        if self.json || matches!(self.format, Some(OutputFormat::Json)) {
206            return Ok(OutputMode::Json);
207        }
208
209        Ok(OutputMode::Text)
210    }
211
212    pub fn command_id(&self) -> &'static str {
213        match self.command {
214            MemoCommand::Add(_) => "memo-cli add",
215            MemoCommand::Update(_) => "memo-cli update",
216            MemoCommand::Delete(_) => "memo-cli delete",
217            MemoCommand::List(_) => "memo-cli list",
218            MemoCommand::Search(_) => "memo-cli search",
219            MemoCommand::Report(_) => "memo-cli report",
220            MemoCommand::Fetch(_) => "memo-cli fetch",
221            MemoCommand::Apply(_) => "memo-cli apply",
222        }
223    }
224
225    pub fn schema_version(&self) -> &'static str {
226        match self.command {
227            MemoCommand::Add(_) => "memo-cli.add.v1",
228            MemoCommand::Update(_) => "memo-cli.update.v1",
229            MemoCommand::Delete(_) => "memo-cli.delete.v1",
230            MemoCommand::List(_) => "memo-cli.list.v1",
231            MemoCommand::Search(_) => "memo-cli.search.v1",
232            MemoCommand::Report(_) => "memo-cli.report.v1",
233            MemoCommand::Fetch(_) => "memo-cli.fetch.v1",
234            MemoCommand::Apply(_) => "memo-cli.apply.v1",
235        }
236    }
237}
238
239fn default_db_path() -> PathBuf {
240    if let Some(data_home) = env::var_os("XDG_DATA_HOME") {
241        return PathBuf::from(data_home).join("nils-cli").join("memo.db");
242    }
243
244    if let Some(home) = env::var_os("HOME") {
245        return PathBuf::from(home)
246            .join(".local")
247            .join("share")
248            .join("nils-cli")
249            .join("memo.db");
250    }
251
252    PathBuf::from("memo.db")
253}
254
255#[cfg(test)]
256pub(crate) mod tests {
257    use clap::{CommandFactory, Parser};
258
259    use super::{Cli, OutputMode};
260
261    #[test]
262    fn output_mode_defaults_to_text() {
263        let cli = Cli::parse_from(["memo-cli", "list"]);
264        let mode = cli.resolve_output_mode().expect("mode should resolve");
265        assert_eq!(mode, OutputMode::Text);
266    }
267
268    #[test]
269    fn output_mode_json_flag_wins() {
270        let cli = Cli::parse_from(["memo-cli", "--json", "list"]);
271        let mode = cli.resolve_output_mode().expect("mode should resolve");
272        assert_eq!(mode, OutputMode::Json);
273    }
274
275    #[test]
276    fn output_mode_format_json_is_supported() {
277        let cli = Cli::parse_from(["memo-cli", "--format", "json", "list"]);
278        let mode = cli.resolve_output_mode().expect("mode should resolve");
279        assert_eq!(mode, OutputMode::Json);
280    }
281
282    #[test]
283    fn output_mode_rejects_conflict() {
284        let cli = Cli::parse_from(["memo-cli", "--json", "--format", "text", "list"]);
285        let err = cli.resolve_output_mode().expect_err("conflict should fail");
286        assert_eq!(err.exit_code(), 64);
287    }
288
289    #[test]
290    fn parser_exposes_expected_subcommands() {
291        let mut cmd = Cli::command();
292        let subcommands = cmd
293            .get_subcommands_mut()
294            .map(|sub| sub.get_name().to_string())
295            .collect::<Vec<_>>();
296        assert!(subcommands.contains(&"add".to_string()));
297        assert!(subcommands.contains(&"update".to_string()));
298        assert!(subcommands.contains(&"delete".to_string()));
299        assert!(subcommands.contains(&"list".to_string()));
300        assert!(subcommands.contains(&"search".to_string()));
301        assert!(subcommands.contains(&"report".to_string()));
302        assert!(subcommands.contains(&"fetch".to_string()));
303        assert!(subcommands.contains(&"apply".to_string()));
304    }
305}