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