Skip to main content

kimun_notes/cli/commands/
note_ops.rs

1// tui/src/cli/commands/note_ops.rs
2//
3// CLI commands for note create, append, and show operations.
4
5use clap::Subcommand;
6use color_eyre::eyre::Result;
7use kimun_core::NoteVault;
8
9const NOTE_SEPARATOR: &str =
10    "================================================================================";
11
12#[derive(Subcommand, Debug)]
13pub enum NoteSubcommand {
14    /// Create a new note (fails if the note already exists)
15    Create {
16        /// Note path, relative to quick_note_path or absolute from vault root
17        path: String,
18        /// Note content (reads from stdin if omitted and stdin is not a TTY)
19        content: Option<String>,
20    },
21    /// Append text to a note (creates the note if it does not exist)
22    Append {
23        /// Note path, relative to quick_note_path or absolute from vault root
24        path: String,
25        /// Text to append (reads from stdin if omitted and stdin is not a TTY)
26        content: Option<String>,
27    },
28    /// Quickly capture a thought into a timestamped inbox note
29    Quick {
30        /// Text content (reads from stdin if omitted and stdin is not a TTY)
31        content: Option<String>,
32    },
33    /// List inbox notes for triage
34    Triage,
35    /// Show note content and metadata (read one or more notes)
36    Show {
37        /// One or more note paths (relative to quick_note_path or absolute from vault root)
38        paths: Vec<String>,
39        #[arg(long, value_enum, default_value = "text")]
40        format: crate::cli::output::OutputFormat,
41    },
42    /// Overwrite a note's entire content (requires --force; the old content is backed up)
43    Overwrite {
44        /// Note path, relative to quick_note_path or absolute from vault root
45        path: String,
46        /// New content (reads from stdin if omitted and stdin is not a TTY)
47        content: Option<String>,
48        /// Required: discards the existing note body
49        #[arg(long)]
50        force: bool,
51    },
52    /// Replace text in a note (literal by default; the match must be unique unless --all)
53    Replace {
54        /// Note path, relative to quick_note_path or absolute from vault root
55        path: String,
56        /// Text to find (a regular expression when --regex is set)
57        old: String,
58        /// Replacement text ($1/${name} capture references work with --regex)
59        new: String,
60        /// Replace every occurrence instead of requiring a unique match
61        #[arg(long)]
62        all: bool,
63        /// Treat the find text as a regular expression instead of a literal substring
64        #[arg(long)]
65        regex: bool,
66        /// Print the resulting note content without writing it (dry run)
67        #[arg(long)]
68        preview: bool,
69    },
70    /// Delete a note (requires --force; the content is backed up first)
71    Delete {
72        /// Note path, relative to quick_note_path or absolute from vault root
73        path: String,
74        /// Required: confirms the deletion
75        #[arg(long)]
76        force: bool,
77    },
78}
79
80pub async fn run(
81    subcommand: NoteSubcommand,
82    vault: &NoteVault,
83    quick_note_path: &str,
84    workspace_name: &str,
85) -> Result<()> {
86    match subcommand {
87        NoteSubcommand::Create { path, content } => {
88            run_create(vault, &path, content, quick_note_path).await
89        }
90        NoteSubcommand::Append { path, content } => {
91            run_append(vault, &path, content, quick_note_path).await
92        }
93        NoteSubcommand::Quick { content } => run_quick(vault, content).await,
94        NoteSubcommand::Triage => run_triage(vault).await,
95        NoteSubcommand::Show { paths, format } => {
96            use std::io::IsTerminal;
97            let reader = if std::io::stdin().is_terminal() {
98                None
99            } else {
100                Some(std::io::BufReader::new(std::io::stdin().lock()))
101            };
102            let resolved = resolve_show_paths(paths, reader)?;
103            run_show(vault, &resolved, quick_note_path, format, workspace_name).await
104        }
105        NoteSubcommand::Overwrite {
106            path,
107            content,
108            force,
109        } => run_overwrite(vault, &path, content, force, quick_note_path).await,
110        NoteSubcommand::Replace {
111            path,
112            old,
113            new,
114            all,
115            regex,
116            preview,
117        } => {
118            run_replace(
119                vault,
120                &path,
121                &old,
122                &new,
123                all,
124                regex,
125                preview,
126                quick_note_path,
127            )
128            .await
129        }
130        NoteSubcommand::Delete { path, force } => {
131            run_delete(vault, &path, force, quick_note_path).await
132        }
133    }
134}
135
136async fn run_overwrite(
137    vault: &NoteVault,
138    path_input: &str,
139    content: Option<String>,
140    force: bool,
141    quick_note_path: &str,
142) -> Result<()> {
143    use crate::cli::helpers::{resolve_content, resolve_note_path};
144
145    if !force {
146        return Err(color_eyre::eyre::eyre!(
147            "Refusing to overwrite without --force (this discards the existing note body)"
148        ));
149    }
150    let vault_path = resolve_note_path(path_input, quick_note_path)?;
151    let text = resolve_content(content)?;
152    if text.is_empty() {
153        return Err(color_eyre::eyre::eyre!(
154            "Refusing to overwrite with empty content (this would wipe the note); pass content, or use `note delete` to remove it"
155        ));
156    }
157
158    // Propagate the typed `VaultError` so the boundary in `main` can classify
159    // it (user error → clean message + exit 2). `?` wraps it preserving the
160    // concrete type for `downcast_ref`; stringifying here would lose it.
161    vault.save_note(&vault_path, &text).await?;
162
163    println!("Note saved: {}", vault_path);
164    Ok(())
165}
166
167#[allow(clippy::too_many_arguments)]
168async fn run_replace(
169    vault: &NoteVault,
170    path_input: &str,
171    old: &str,
172    new: &str,
173    all: bool,
174    regex: bool,
175    preview: bool,
176    quick_note_path: &str,
177) -> Result<()> {
178    use crate::cli::helpers::resolve_note_path;
179
180    let vault_path = resolve_note_path(path_input, quick_note_path)?;
181
182    if preview {
183        let pv = vault
184            .preview_replace(&vault_path, old, new, all, regex)
185            .await?;
186        // Report the count on stderr so stdout is just the resulting content
187        // (pipe-friendly, e.g. into a diff).
188        eprintln!(
189            "{} occurrence(s) would be replaced in {} (preview — not written)",
190            pv.count, vault_path
191        );
192        print!("{}", pv.content);
193        return Ok(());
194    }
195
196    let count = vault
197        .replace_in_note(&vault_path, old, new, all, regex)
198        .await?;
199
200    println!("Replaced {} occurrence(s) in {}", count, vault_path);
201    Ok(())
202}
203
204async fn run_delete(
205    vault: &NoteVault,
206    path_input: &str,
207    force: bool,
208    quick_note_path: &str,
209) -> Result<()> {
210    use crate::cli::helpers::resolve_note_path;
211
212    if !force {
213        return Err(color_eyre::eyre::eyre!(
214            "Refusing to delete without --force"
215        ));
216    }
217    let vault_path = resolve_note_path(path_input, quick_note_path)?;
218
219    vault.delete_note(&vault_path).await?;
220
221    println!("Note deleted: {}", vault_path);
222    Ok(())
223}
224
225async fn run_create(
226    vault: &NoteVault,
227    path_input: &str,
228    content: Option<String>,
229    quick_note_path: &str,
230) -> Result<()> {
231    use crate::cli::helpers::{resolve_content, resolve_note_path};
232
233    let vault_path = resolve_note_path(path_input, quick_note_path)?;
234    let text = resolve_content(content)?;
235
236    vault.create_note(&vault_path, &text).await?;
237
238    println!("Note saved: {}", vault_path);
239    Ok(())
240}
241
242async fn run_append(
243    vault: &NoteVault,
244    path_input: &str,
245    content: Option<String>,
246    quick_note_path: &str,
247) -> Result<()> {
248    use crate::cli::helpers::{resolve_content, resolve_note_path};
249
250    let vault_path = resolve_note_path(path_input, quick_note_path)?;
251    let text = resolve_content(content)?;
252
253    if text.is_empty() {
254        return Ok(());
255    }
256
257    vault.append_to_note(&vault_path, &text, None).await?;
258
259    println!("Note saved: {}", vault_path);
260    Ok(())
261}
262
263pub(crate) fn format_note_show_text(
264    path: &kimun_core::nfs::VaultPath,
265    content: &str,
266    title: &str,
267    tags: &[String],
268    links: &[String],
269    backlinks: &[String],
270) -> String {
271    let mut out = String::new();
272    out.push_str(&format!("Path:      {}\n", path));
273    if !title.is_empty() {
274        out.push_str(&format!("Title:     {}\n", title));
275    }
276    if !tags.is_empty() {
277        out.push_str(&format!("Tags:      {}\n", tags.join(" ")));
278    }
279    if !links.is_empty() {
280        out.push_str(&format!("Links:     {}\n", links.join(", ")));
281    }
282    if !backlinks.is_empty() {
283        out.push_str(&format!("Backlinks: {}\n", backlinks.join(", ")));
284    }
285    out.push_str("---\n");
286    out.push_str(content);
287    out
288}
289
290/// Resolves the effective path list for `note show`.
291/// - If `args` is non-empty, returns it directly (reader is ignored).
292/// - If `args` is empty and `reader` is `Some`, reads non-blank trimmed lines from it.
293/// - If `args` is empty and `reader` is `None` (TTY), returns an error.
294fn resolve_show_paths<R: std::io::BufRead>(
295    args: Vec<String>,
296    reader: Option<R>,
297) -> color_eyre::eyre::Result<Vec<String>> {
298    if !args.is_empty() {
299        return Ok(args);
300    }
301    match reader {
302        Some(r) => {
303            let paths: Result<Vec<String>, _> = r
304                .lines()
305                .filter(|l| l.as_ref().map(|s| !s.trim().is_empty()).unwrap_or(true))
306                .map(|l| l.map(|s| s.trim().split('\t').next().unwrap_or("").to_owned()))
307                .collect();
308            let paths =
309                paths.map_err(|e| color_eyre::eyre::eyre!("Failed to read stdin: {}", e))?;
310            if paths.is_empty() {
311                return Err(color_eyre::eyre::eyre!(
312                    "No paths provided — pass paths as arguments or pipe from stdin"
313                ));
314            }
315            Ok(paths)
316        }
317        None => Err(color_eyre::eyre::eyre!(
318            "No paths provided — pass paths as arguments or pipe from stdin"
319        )),
320    }
321}
322
323async fn run_show(
324    vault: &NoteVault,
325    path_inputs: &[String],
326    quick_note_path: &str,
327    format: crate::cli::output::OutputFormat,
328    workspace_name: &str,
329) -> Result<()> {
330    use crate::cli::helpers::resolve_note_path;
331    use crate::cli::json_output::{
332        JsonNoteEntry, JsonNoteMetadata, JsonOutput, JsonOutputMetadata,
333    };
334    use crate::cli::metadata_extractor::{extract_headers, extract_links, extract_tags};
335    use crate::cli::output::OutputFormat;
336    use chrono::Utc;
337    use kimun_core::nfs::NoteEntryData;
338    use std::time::UNIX_EPOCH;
339
340    if matches!(format, OutputFormat::Paths) {
341        return Err(color_eyre::eyre::eyre!(
342            "--format paths is not valid for note show; use 'text' or 'json'"
343        ));
344    }
345
346    // One accumulator per format — only the active one is ever populated.
347    enum Accumulator {
348        Text(Vec<String>),
349        Json(Vec<JsonNoteEntry>),
350    }
351
352    let mut acc = match format {
353        OutputFormat::Text => Accumulator::Text(Vec::new()),
354        OutputFormat::Json => Accumulator::Json(Vec::new()),
355        OutputFormat::Paths => unreachable!("guarded above"),
356    };
357    let mut had_errors = false;
358
359    for input in path_inputs {
360        let vault_path = match resolve_note_path(input, quick_note_path) {
361            Ok(p) => p,
362            Err(e) => {
363                eprintln!("Error: {}", e);
364                had_errors = true;
365                continue;
366            }
367        };
368
369        let note_details = match vault.load_note(&vault_path).await {
370            Ok(nd) => nd,
371            // A missing note is a per-note miss (record, keep going); any other
372            // user error prints the same core message; internal errors abort.
373            // Same wording as the single-note path and the MCP server.
374            Err(e) if e.is_not_found() => {
375                eprintln!(
376                    "Error: {}",
377                    e.user_message().unwrap_or_else(|| e.to_string())
378                );
379                had_errors = true;
380                continue;
381            }
382            Err(e) => return Err(color_eyre::eyre::eyre!("{}", e)),
383        };
384
385        let content = &note_details.raw_text;
386        let content_data = note_details.get_content_data();
387
388        let backlink_results = vault
389            .get_backlinks(&vault_path)
390            .await
391            .map_err(|e| color_eyre::eyre::eyre!("{}", e))?;
392        let backlink_paths: Vec<String> = backlink_results
393            .iter()
394            .map(|(e, _)| e.path.to_string())
395            .collect();
396
397        match &mut acc {
398            Accumulator::Text(entries) => {
399                let tags = extract_tags(content);
400                let links = extract_links(content);
401                entries.push(format_note_show_text(
402                    &vault_path,
403                    content,
404                    &content_data.title,
405                    &tags,
406                    &links,
407                    &backlink_paths,
408                ));
409            }
410            Accumulator::Json(entries) => {
411                let meta = tokio::fs::metadata(vault.path_to_pathbuf(&vault_path))
412                    .await
413                    .map_err(|e| color_eyre::eyre::eyre!("{}", e))?;
414                let modified_secs = meta
415                    .modified()
416                    .map(|t| t.duration_since(UNIX_EPOCH).unwrap_or_default().as_secs())
417                    .unwrap_or(0);
418                let entry_data = NoteEntryData {
419                    path: vault_path.clone(),
420                    size: meta.len(),
421                    modified_secs,
422                };
423                let tags = extract_tags(content);
424                let links = extract_links(content);
425                let headers = extract_headers(content);
426                let journal_date = vault
427                    .journal_date(&vault_path)
428                    .map(|d| d.format("%Y-%m-%d").to_string());
429                entries.push(JsonNoteEntry {
430                    path: vault_path.to_string_with_ext(),
431                    title: content_data.title.clone(),
432                    content: content.clone(),
433                    size: entry_data.size,
434                    modified: entry_data.modified_secs,
435                    created: entry_data.modified_secs, // TODO: track actual creation time
436                    hash: format!("{:x}", content_data.hash),
437                    journal_date,
438                    metadata: JsonNoteMetadata {
439                        tags,
440                        links,
441                        headers,
442                    },
443                    backlinks: if backlink_paths.is_empty() {
444                        None
445                    } else {
446                        Some(backlink_paths)
447                    },
448                });
449            }
450        }
451    }
452
453    let is_empty = match &acc {
454        Accumulator::Text(v) => v.is_empty(),
455        Accumulator::Json(v) => v.is_empty(),
456    };
457    if is_empty {
458        return Err(color_eyre::eyre::eyre!(
459            "No notes found — all specified paths were missing"
460        ));
461    }
462
463    // Output whatever was found — the JSON/text is valid for the notes that succeeded.
464    // had_errors (non-zero exit) signals that some notes were missing; those were
465    // already reported to stderr in the loop above.
466    match acc {
467        Accumulator::Text(entries) => {
468            let sep = format!("\n{}\n\n", NOTE_SEPARATOR);
469            print!("{}", entries.join(&sep));
470        }
471        Accumulator::Json(notes) => {
472            let output = JsonOutput {
473                metadata: JsonOutputMetadata {
474                    workspace: workspace_name.to_string(),
475                    workspace_path: vault.workspace_path().to_string_lossy().to_string(),
476                    total_results: notes.len(),
477                    query: None,
478                    is_listing: false,
479                    generated_at: Utc::now().to_rfc3339(),
480                },
481                notes,
482            };
483            print!(
484                "{}",
485                serde_json::to_string(&output).map_err(|e| color_eyre::eyre::eyre!("{}", e))?
486            );
487        }
488    }
489
490    if had_errors {
491        return Err(color_eyre::eyre::eyre!(
492            "One or more notes could not be found"
493        ));
494    }
495
496    Ok(())
497}
498
499async fn run_triage(vault: &NoteVault) -> Result<()> {
500    let inbox_notes = vault
501        .get_notes(vault.inbox_path(), false)
502        .await
503        .map_err(|e| color_eyre::eyre::eyre!("{}", e))?;
504
505    if inbox_notes.is_empty() {
506        println!("Inbox is empty.");
507        return Ok(());
508    }
509
510    println!("Inbox notes ({}):\n", inbox_notes.len());
511    for (entry, content_data) in &inbox_notes {
512        let title = if content_data.title.trim().is_empty() {
513            "<no title>"
514        } else {
515            &content_data.title
516        };
517        println!("  {} — {}", entry.path, title);
518    }
519
520    Ok(())
521}
522
523async fn run_quick(vault: &NoteVault, content: Option<String>) -> Result<()> {
524    use crate::cli::helpers::resolve_content;
525
526    let text = resolve_content(content)?;
527    if text.is_empty() {
528        return Ok(());
529    }
530
531    let details = vault
532        .quick_note(&text)
533        .await
534        .map_err(|e| color_eyre::eyre::eyre!("{}", e))?;
535
536    println!("Note saved: {}", details.path);
537    Ok(())
538}
539
540#[cfg(test)]
541mod tests {
542    use super::resolve_show_paths;
543    use std::io::Cursor;
544
545    #[test]
546    fn test_resolve_show_paths_uses_args_when_given() {
547        let args = vec!["projects/foo".to_string(), "inbox/bar".to_string()];
548        let result = resolve_show_paths(args.clone(), None::<Cursor<&[u8]>>).unwrap();
549        assert_eq!(result, args);
550    }
551
552    #[test]
553    fn test_resolve_show_paths_reads_from_reader() {
554        let input = b"projects/foo\ninbox/bar\n";
555        let reader = Cursor::new(input.as_ref());
556        let result = resolve_show_paths(vec![], Some(reader)).unwrap();
557        assert_eq!(result, vec!["projects/foo", "inbox/bar"]);
558    }
559
560    #[test]
561    fn test_resolve_show_paths_skips_blank_lines() {
562        let input = b"projects/foo\n\n  \ninbox/bar\n";
563        let reader = Cursor::new(input.as_ref());
564        let result = resolve_show_paths(vec![], Some(reader)).unwrap();
565        assert_eq!(result, vec!["projects/foo", "inbox/bar"]);
566    }
567
568    #[test]
569    fn test_resolve_show_paths_all_blank_stdin_returns_empty() {
570        let input = b"\n  \n\t\n";
571        let reader = Cursor::new(input.as_ref());
572        let result = resolve_show_paths(vec![], Some(reader));
573        assert!(result.is_err());
574        let msg = result.unwrap_err().to_string();
575        assert!(msg.contains("No paths provided"), "got: {}", msg);
576    }
577
578    #[test]
579    fn test_resolve_show_paths_strips_tab_separated_fields() {
580        // kimun notes outputs tab-separated lines: path\ttitle\tsize\ttimestamp
581        let input = b"projects/foo\tFoo Note\t1234\t1700000000\ninbox/bar\tBar\t42\t1700000001\n";
582        let reader = Cursor::new(input.as_ref());
583        let result = resolve_show_paths(vec![], Some(reader)).unwrap();
584        assert_eq!(result, vec!["projects/foo", "inbox/bar"]);
585    }
586
587    #[test]
588    fn test_resolve_show_paths_no_args_no_reader_errors() {
589        let result = resolve_show_paths(vec![], None::<Cursor<&[u8]>>);
590        assert!(result.is_err());
591        let msg = result.unwrap_err().to_string();
592        assert!(msg.contains("No paths provided"), "got: {}", msg);
593    }
594}