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    /// List memo entries in deterministic order
72    List(ListArgs),
73    /// Search memo entries with FTS-backed query
74    Search(SearchArgs),
75    /// Show weekly or monthly summary report
76    Report(ReportArgs),
77    /// Fetch pending items for agent enrichment
78    Fetch(FetchArgs),
79    /// Apply enrichment payloads
80    Apply(ApplyArgs),
81}
82
83#[derive(Debug, clap::Args)]
84pub struct AddArgs {
85    /// Memo text
86    pub text: String,
87
88    /// Capture source label
89    #[arg(long, default_value = "cli")]
90    pub source: String,
91
92    /// Capture timestamp (RFC3339)
93    #[arg(long)]
94    pub at: Option<String>,
95}
96
97#[derive(Debug, clap::Args)]
98pub struct ListArgs {
99    /// Max rows to return
100    #[arg(long, default_value_t = 20)]
101    pub limit: usize,
102
103    /// Row offset for paging
104    #[arg(long, default_value_t = 0)]
105    pub offset: usize,
106
107    /// Row selection mode
108    #[arg(long, value_enum, default_value_t = ItemState::All)]
109    pub state: ItemState,
110}
111
112#[derive(Debug, clap::Args)]
113pub struct SearchArgs {
114    /// Search query text (FTS syntax)
115    pub query: String,
116
117    /// Max rows to return
118    #[arg(long, default_value_t = 20)]
119    pub limit: usize,
120
121    /// Row selection mode
122    #[arg(long, value_enum, default_value_t = ItemState::All)]
123    pub state: ItemState,
124}
125
126#[derive(Debug, clap::Args)]
127pub struct ReportArgs {
128    /// Report period: week or month
129    pub period: ReportPeriod,
130
131    /// IANA timezone for canonical period windows
132    #[arg(long)]
133    pub tz: Option<String>,
134
135    /// Custom report start timestamp (RFC3339)
136    #[arg(long)]
137    pub from: Option<String>,
138
139    /// Custom report end timestamp (RFC3339)
140    #[arg(long)]
141    pub to: Option<String>,
142}
143
144#[derive(Debug, clap::Args)]
145pub struct FetchArgs {
146    /// Max rows to return
147    #[arg(long, default_value_t = 50)]
148    pub limit: usize,
149
150    /// Optional cursor (reserved for future pagination)
151    #[arg(long)]
152    pub cursor: Option<String>,
153
154    /// Fetch selection mode
155    #[arg(long, value_enum, default_value_t = FetchState::Pending)]
156    pub state: FetchState,
157}
158
159#[derive(Debug, clap::Args)]
160pub struct ApplyArgs {
161    /// JSON file containing apply payload
162    #[arg(long)]
163    pub input: Option<PathBuf>,
164
165    /// Read payload JSON from stdin
166    #[arg(long)]
167    pub stdin: bool,
168
169    /// Validate payload without write-back
170    #[arg(long)]
171    pub dry_run: bool,
172}
173
174impl Cli {
175    pub fn resolve_output_mode(&self) -> Result<OutputMode, AppError> {
176        if self.json && matches!(self.format, Some(OutputFormat::Text)) {
177            return Err(AppError::usage(
178                "invalid output mode: --json cannot be combined with --format text",
179            ));
180        }
181
182        if self.json || matches!(self.format, Some(OutputFormat::Json)) {
183            return Ok(OutputMode::Json);
184        }
185
186        Ok(OutputMode::Text)
187    }
188
189    pub fn command_id(&self) -> &'static str {
190        match self.command {
191            MemoCommand::Add(_) => "memo-cli add",
192            MemoCommand::List(_) => "memo-cli list",
193            MemoCommand::Search(_) => "memo-cli search",
194            MemoCommand::Report(_) => "memo-cli report",
195            MemoCommand::Fetch(_) => "memo-cli fetch",
196            MemoCommand::Apply(_) => "memo-cli apply",
197        }
198    }
199
200    pub fn schema_version(&self) -> &'static str {
201        match self.command {
202            MemoCommand::Add(_) => "memo-cli.add.v1",
203            MemoCommand::List(_) => "memo-cli.list.v1",
204            MemoCommand::Search(_) => "memo-cli.search.v1",
205            MemoCommand::Report(_) => "memo-cli.report.v1",
206            MemoCommand::Fetch(_) => "memo-cli.fetch.v1",
207            MemoCommand::Apply(_) => "memo-cli.apply.v1",
208        }
209    }
210}
211
212fn default_db_path() -> PathBuf {
213    if let Some(data_home) = env::var_os("XDG_DATA_HOME") {
214        return PathBuf::from(data_home).join("nils-cli").join("memo.db");
215    }
216
217    if let Some(home) = env::var_os("HOME") {
218        return PathBuf::from(home)
219            .join(".local")
220            .join("share")
221            .join("nils-cli")
222            .join("memo.db");
223    }
224
225    PathBuf::from("memo.db")
226}
227
228#[cfg(test)]
229pub(crate) mod tests {
230    use clap::{CommandFactory, Parser};
231
232    use super::{Cli, OutputMode};
233
234    #[test]
235    fn output_mode_defaults_to_text() {
236        let cli = Cli::parse_from(["memo-cli", "list"]);
237        let mode = cli.resolve_output_mode().expect("mode should resolve");
238        assert_eq!(mode, OutputMode::Text);
239    }
240
241    #[test]
242    fn output_mode_json_flag_wins() {
243        let cli = Cli::parse_from(["memo-cli", "--json", "list"]);
244        let mode = cli.resolve_output_mode().expect("mode should resolve");
245        assert_eq!(mode, OutputMode::Json);
246    }
247
248    #[test]
249    fn output_mode_format_json_is_supported() {
250        let cli = Cli::parse_from(["memo-cli", "--format", "json", "list"]);
251        let mode = cli.resolve_output_mode().expect("mode should resolve");
252        assert_eq!(mode, OutputMode::Json);
253    }
254
255    #[test]
256    fn output_mode_rejects_conflict() {
257        let cli = Cli::parse_from(["memo-cli", "--json", "--format", "text", "list"]);
258        let err = cli.resolve_output_mode().expect_err("conflict should fail");
259        assert_eq!(err.exit_code(), 64);
260    }
261
262    #[test]
263    fn parser_exposes_expected_subcommands() {
264        let mut cmd = Cli::command();
265        let subcommands = cmd
266            .get_subcommands_mut()
267            .map(|sub| sub.get_name().to_string())
268            .collect::<Vec<_>>();
269        assert!(subcommands.contains(&"add".to_string()));
270        assert!(subcommands.contains(&"list".to_string()));
271        assert!(subcommands.contains(&"search".to_string()));
272        assert!(subcommands.contains(&"report".to_string()));
273        assert!(subcommands.contains(&"fetch".to_string()));
274        assert!(subcommands.contains(&"apply".to_string()));
275    }
276}