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
338    if matches!(format, OutputFormat::Paths) {
339        return Err(color_eyre::eyre::eyre!(
340            "--format paths is not valid for note show; use 'text' or 'json'"
341        ));
342    }
343
344    // One accumulator per format — only the active one is ever populated.
345    enum Accumulator {
346        Text(Vec<String>),
347        Json(Vec<JsonNoteEntry>),
348    }
349
350    let mut acc = match format {
351        OutputFormat::Text => Accumulator::Text(Vec::new()),
352        OutputFormat::Json => Accumulator::Json(Vec::new()),
353        OutputFormat::Paths => unreachable!("guarded above"),
354    };
355    let mut had_errors = false;
356
357    for input in path_inputs {
358        let vault_path = match resolve_note_path(input, quick_note_path) {
359            Ok(p) => p,
360            Err(e) => {
361                eprintln!("Error: {}", e);
362                had_errors = true;
363                continue;
364            }
365        };
366
367        let note_details = match vault.load_note(&vault_path).await {
368            Ok(nd) => nd,
369            // A missing note is a per-note miss (record, keep going); any other
370            // user error prints the same core message; internal errors abort.
371            // Same wording as the single-note path and the MCP server.
372            Err(e) if e.is_not_found() => {
373                eprintln!(
374                    "Error: {}",
375                    e.user_message().unwrap_or_else(|| e.to_string())
376                );
377                had_errors = true;
378                continue;
379            }
380            Err(e) => return Err(color_eyre::eyre::eyre!("{}", e)),
381        };
382
383        let content = &note_details.raw_text;
384        let content_data = note_details.get_content_data();
385
386        let backlink_results = vault
387            .get_backlinks(&vault_path)
388            .await
389            .map_err(|e| color_eyre::eyre::eyre!("{}", e))?;
390        let backlink_paths: Vec<String> = backlink_results
391            .iter()
392            .map(|(e, _)| e.path.to_string())
393            .collect();
394
395        match &mut acc {
396            Accumulator::Text(entries) => {
397                let tags = extract_tags(content);
398                let links = extract_links(content);
399                entries.push(format_note_show_text(
400                    &vault_path,
401                    content,
402                    &content_data.title,
403                    &tags,
404                    &links,
405                    &backlink_paths,
406                ));
407            }
408            Accumulator::Json(entries) => {
409                let entry_data = vault
410                    .note_entry(&vault_path)
411                    .await
412                    .map_err(|e| color_eyre::eyre::eyre!("{}", e))?;
413                let tags = extract_tags(content);
414                let links = extract_links(content);
415                let headers = extract_headers(content);
416                let journal_date = vault
417                    .journal_date(&vault_path)
418                    .map(|d| d.format("%Y-%m-%d").to_string());
419                entries.push(JsonNoteEntry {
420                    path: vault_path.to_string_with_ext(),
421                    title: content_data.title.clone(),
422                    content: content.clone(),
423                    size: entry_data.size,
424                    modified: entry_data.modified_secs,
425                    created: entry_data.modified_secs, // TODO: track actual creation time
426                    hash: format!("{:x}", content_data.hash),
427                    journal_date,
428                    metadata: JsonNoteMetadata {
429                        tags,
430                        links,
431                        headers,
432                    },
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}