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 #[arg(long, global = true, value_name = "path", default_value_os_t = default_db_path())]
67 pub db: PathBuf,
68
69 #[arg(long, global = true)]
71 pub json: bool,
72
73 #[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 Add(AddArgs),
85 Update(UpdateArgs),
87 Delete(DeleteArgs),
89 List(ListArgs),
91 Search(SearchArgs),
93 Report(ReportArgs),
95 Fetch(FetchArgs),
97 Apply(ApplyArgs),
99}
100
101#[derive(Debug, clap::Args)]
102pub struct AddArgs {
103 pub text: String,
105
106 #[arg(long, default_value = "cli")]
108 pub source: String,
109
110 #[arg(long)]
112 pub at: Option<String>,
113}
114
115#[derive(Debug, clap::Args)]
116pub struct UpdateArgs {
117 pub item_id: String,
119
120 pub text: String,
122}
123
124#[derive(Debug, clap::Args)]
125pub struct DeleteArgs {
126 pub item_id: String,
128
129 #[arg(long)]
131 pub hard: bool,
132}
133
134#[derive(Debug, clap::Args)]
135pub struct ListArgs {
136 #[arg(long, default_value_t = 20)]
138 pub limit: usize,
139
140 #[arg(long, default_value_t = 0)]
142 pub offset: usize,
143
144 #[arg(long, value_enum, default_value_t = ItemState::All)]
146 pub state: ItemState,
147}
148
149#[derive(Debug, clap::Args)]
150pub struct SearchArgs {
151 pub query: String,
153
154 #[arg(long, default_value_t = 20)]
156 pub limit: usize,
157
158 #[arg(long, value_enum, default_value_t = ItemState::All)]
160 pub state: ItemState,
161
162 #[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 #[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 pub period: ReportPeriod,
180
181 #[arg(long)]
183 pub tz: Option<String>,
184
185 #[arg(long)]
187 pub from: Option<String>,
188
189 #[arg(long)]
191 pub to: Option<String>,
192}
193
194#[derive(Debug, clap::Args)]
195pub struct FetchArgs {
196 #[arg(long, default_value_t = 50)]
198 pub limit: usize,
199
200 #[arg(long)]
202 pub cursor: Option<String>,
203
204 #[arg(long, value_enum, default_value_t = FetchState::Pending)]
206 pub state: FetchState,
207}
208
209#[derive(Debug, clap::Args)]
210pub struct ApplyArgs {
211 #[arg(long)]
213 pub input: Option<PathBuf>,
214
215 #[arg(long)]
217 pub stdin: bool,
218
219 #[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}