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, error::VaultError};
8
9const NOTE_SEPARATOR: &str = "================================================================================";
10
11#[derive(Subcommand, Debug)]
12pub enum NoteSubcommand {
13    /// Create a new note (fails if the note already exists)
14    Create {
15        /// Note path, relative to quick_note_path or absolute from vault root
16        path: String,
17        /// Note content (reads from stdin if omitted and stdin is not a TTY)
18        content: Option<String>,
19    },
20    /// Append text to a note (creates the note if it does not exist)
21    Append {
22        /// Note path, relative to quick_note_path or absolute from vault root
23        path: String,
24        /// Text to append (reads from stdin if omitted and stdin is not a TTY)
25        content: Option<String>,
26    },
27    /// Show note content and metadata (read one or more notes)
28    Show {
29        /// One or more note paths (relative to quick_note_path or absolute from vault root)
30        paths: Vec<String>,
31        #[arg(long, value_enum, default_value = "text")]
32        format: crate::cli::output::OutputFormat,
33    },
34}
35
36pub async fn run(
37    subcommand: NoteSubcommand,
38    vault: &NoteVault,
39    quick_note_path: &str,
40    workspace_name: &str,
41) -> Result<()> {
42    match subcommand {
43        NoteSubcommand::Create { path, content } => {
44            run_create(vault, &path, content, quick_note_path).await
45        }
46        NoteSubcommand::Append { path, content } => {
47            run_append(vault, &path, content, quick_note_path).await
48        }
49        NoteSubcommand::Show { paths, format } => {
50            use std::io::IsTerminal;
51            let reader = if std::io::stdin().is_terminal() {
52                None
53            } else {
54                Some(std::io::BufReader::new(std::io::stdin().lock()))
55            };
56            let resolved = resolve_show_paths(paths, reader)?;
57            run_show(vault, &resolved, quick_note_path, format, workspace_name).await
58        }
59    }
60}
61
62async fn run_create(
63    vault: &NoteVault,
64    path_input: &str,
65    content: Option<String>,
66    quick_note_path: &str,
67) -> Result<()> {
68    use crate::cli::helpers::{resolve_note_path, resolve_content};
69
70    let vault_path = resolve_note_path(path_input, quick_note_path)?;
71    let text = resolve_content(content)?;
72
73    vault.create_note(&vault_path, &text).await.map_err(|e| {
74        match &e {
75            VaultError::NoteExists { path } => {
76                color_eyre::eyre::eyre!("Note already exists: {}", path)
77            }
78            _ => color_eyre::eyre::eyre!("{}", e),
79        }
80    })?;
81
82    println!("Note saved: {}", vault_path);
83    Ok(())
84}
85
86async fn run_append(
87    vault: &NoteVault,
88    path_input: &str,
89    content: Option<String>,
90    quick_note_path: &str,
91) -> Result<()> {
92    use crate::cli::helpers::{resolve_note_path, resolve_content};
93    use kimun_core::error::FSError;
94
95    let vault_path = resolve_note_path(path_input, quick_note_path)?;
96    let text = resolve_content(content)?;
97
98    if text.is_empty() {
99        return Ok(());
100    }
101
102    match vault.get_note_text(&vault_path).await {
103        Ok(existing) => {
104            let combined = format!("{}\n{}", existing, text);
105            vault.save_note(&vault_path, &combined).await
106                .map_err(|e| color_eyre::eyre::eyre!("{}", e))?;
107        }
108        Err(VaultError::FSError(FSError::VaultPathNotFound { .. })) => {
109            match vault.create_note(&vault_path, &text).await {
110                Ok(_) => {}
111                Err(VaultError::NoteExists { .. }) => {
112                    // Race: note created between our get and create — re-read and save
113                    let existing = vault.get_note_text(&vault_path).await
114                        .map_err(|e| color_eyre::eyre::eyre!("{}", e))?;
115                    let combined = format!("{}\n{}", existing, text);
116                    vault.save_note(&vault_path, &combined).await
117                        .map_err(|e| color_eyre::eyre::eyre!("{}", e))?;
118                }
119                Err(e) => return Err(color_eyre::eyre::eyre!("{}", e)),
120            }
121        }
122        Err(e) => return Err(color_eyre::eyre::eyre!("{}", e)),
123    }
124
125    println!("Note saved: {}", vault_path);
126    Ok(())
127}
128
129pub(crate) fn format_note_show_text(
130    path: &kimun_core::nfs::VaultPath,
131    content: &str,
132    title: &str,
133    tags: &[String],
134    links: &[String],
135    backlinks: &[String],
136) -> String {
137    let mut out = String::new();
138    out.push_str(&format!("Path:      {}\n", path));
139    if !title.is_empty() {
140        out.push_str(&format!("Title:     {}\n", title));
141    }
142    if !tags.is_empty() {
143        out.push_str(&format!("Tags:      {}\n", tags.join(" ")));
144    }
145    if !links.is_empty() {
146        out.push_str(&format!("Links:     {}\n", links.join(", ")));
147    }
148    if !backlinks.is_empty() {
149        out.push_str(&format!("Backlinks: {}\n", backlinks.join(", ")));
150    }
151    out.push_str("---\n");
152    out.push_str(content);
153    out
154}
155
156/// Resolves the effective path list for `note show`.
157/// - If `args` is non-empty, returns it directly (reader is ignored).
158/// - If `args` is empty and `reader` is `Some`, reads non-blank trimmed lines from it.
159/// - If `args` is empty and `reader` is `None` (TTY), returns an error.
160fn resolve_show_paths<R: std::io::BufRead>(
161    args: Vec<String>,
162    reader: Option<R>,
163) -> color_eyre::eyre::Result<Vec<String>> {
164    if !args.is_empty() {
165        return Ok(args);
166    }
167    match reader {
168        Some(r) => {
169            let paths: Result<Vec<String>, _> = r
170                .lines()
171                .filter(|l| l.as_ref().map(|s| !s.trim().is_empty()).unwrap_or(true))
172                .map(|l| l.map(|s| s.trim().split('\t').next().unwrap_or("").to_owned()))
173                .collect();
174            let paths = paths.map_err(|e| color_eyre::eyre::eyre!("Failed to read stdin: {}", e))?;
175            if paths.is_empty() {
176                return Err(color_eyre::eyre::eyre!(
177                    "No paths provided — pass paths as arguments or pipe from stdin"
178                ));
179            }
180            Ok(paths)
181        }
182        None => Err(color_eyre::eyre::eyre!(
183            "No paths provided — pass paths as arguments or pipe from stdin"
184        )),
185    }
186}
187
188async fn run_show(
189    vault: &NoteVault,
190    path_inputs: &[String],
191    quick_note_path: &str,
192    format: crate::cli::output::OutputFormat,
193    workspace_name: &str,
194) -> Result<()> {
195    use crate::cli::helpers::resolve_note_path;
196    use crate::cli::metadata_extractor::{extract_tags, extract_links, extract_headers};
197    use crate::cli::json_output::{JsonNoteEntry, JsonNoteMetadata, JsonOutput, JsonOutputMetadata};
198    use crate::cli::output::OutputFormat;
199    use kimun_core::nfs::NoteEntryData;
200    use kimun_core::error::{VaultError, FSError};
201    use chrono::Utc;
202    use std::time::UNIX_EPOCH;
203
204    if matches!(format, OutputFormat::Paths) {
205        return Err(color_eyre::eyre::eyre!(
206            "--format paths is not valid for note show; use 'text' or 'json'"
207        ));
208    }
209
210    // One accumulator per format — only the active one is ever populated.
211    enum Accumulator {
212        Text(Vec<String>),
213        Json(Vec<JsonNoteEntry>),
214    }
215
216    let mut acc = match format {
217        OutputFormat::Text => Accumulator::Text(Vec::new()),
218        OutputFormat::Json => Accumulator::Json(Vec::new()),
219        OutputFormat::Paths => unreachable!("guarded above"),
220    };
221    let mut had_errors = false;
222
223    for input in path_inputs {
224        let vault_path = match resolve_note_path(input, quick_note_path) {
225            Ok(p) => p,
226            Err(e) => {
227                eprintln!("Error: {}", e);
228                had_errors = true;
229                continue;
230            }
231        };
232
233        let note_details = match vault.load_note(&vault_path).await {
234            Ok(nd) => nd,
235            Err(VaultError::FSError(FSError::VaultPathNotFound { .. })) => {
236                eprintln!("Error: Note not found: {}", vault_path);
237                had_errors = true;
238                continue;
239            }
240            Err(e) => return Err(color_eyre::eyre::eyre!("{}", e)),
241        };
242
243        let content = &note_details.raw_text;
244        let content_data = note_details.get_content_data();
245
246        let backlink_results = vault
247            .get_backlinks(&vault_path)
248            .await
249            .map_err(|e| color_eyre::eyre::eyre!("{}", e))?;
250        let backlink_paths: Vec<String> = backlink_results
251            .iter()
252            .map(|(e, _)| e.path.to_string())
253            .collect();
254
255        match &mut acc {
256            Accumulator::Text(entries) => {
257                let tags = extract_tags(content);
258                let links = extract_links(content);
259                entries.push(format_note_show_text(
260                    &vault_path,
261                    content,
262                    &content_data.title,
263                    &tags,
264                    &links,
265                    &backlink_paths,
266                ));
267            }
268            Accumulator::Json(entries) => {
269                let meta = tokio::fs::metadata(vault.path_to_pathbuf(&vault_path))
270                    .await
271                    .map_err(|e| color_eyre::eyre::eyre!("{}", e))?;
272                let modified_secs = meta
273                    .modified()
274                    .map(|t| t.duration_since(UNIX_EPOCH).unwrap_or_default().as_secs())
275                    .unwrap_or(0);
276                let entry_data = NoteEntryData {
277                    path: vault_path.clone(),
278                    size: meta.len(),
279                    modified_secs,
280                };
281                let tags = extract_tags(content);
282                let links = extract_links(content);
283                let headers = extract_headers(content);
284                let journal_date = vault
285                    .journal_date(&vault_path)
286                    .map(|d| d.format("%Y-%m-%d").to_string());
287                entries.push(JsonNoteEntry {
288                    path: vault_path.to_string_with_ext(),
289                    title: content_data.title.clone(),
290                    content: content.clone(),
291                    size: entry_data.size,
292                    modified: entry_data.modified_secs,
293                    created: entry_data.modified_secs, // TODO: track actual creation time
294                    hash: format!("{:x}", content_data.hash),
295                    journal_date,
296                    metadata: JsonNoteMetadata { tags, links, headers },
297                    backlinks: if backlink_paths.is_empty() { None } else { Some(backlink_paths) },
298                });
299            }
300        }
301    }
302
303    let is_empty = match &acc {
304        Accumulator::Text(v) => v.is_empty(),
305        Accumulator::Json(v) => v.is_empty(),
306    };
307    if is_empty {
308        return Err(color_eyre::eyre::eyre!(
309            "No notes found — all specified paths were missing"
310        ));
311    }
312
313    // Output whatever was found — the JSON/text is valid for the notes that succeeded.
314    // had_errors (non-zero exit) signals that some notes were missing; those were
315    // already reported to stderr in the loop above.
316    match acc {
317        Accumulator::Text(entries) => {
318            let sep = format!("\n{}\n\n", NOTE_SEPARATOR);
319            print!("{}", entries.join(&sep));
320        }
321        Accumulator::Json(notes) => {
322            let output = JsonOutput {
323                metadata: JsonOutputMetadata {
324                    workspace: workspace_name.to_string(),
325                    workspace_path: vault.workspace_path.to_string_lossy().to_string(),
326                    total_results: notes.len(),
327                    query: None,
328                    is_listing: false,
329                    generated_at: Utc::now().to_rfc3339(),
330                },
331                notes,
332            };
333            print!(
334                "{}",
335                serde_json::to_string(&output)
336                    .map_err(|e| color_eyre::eyre::eyre!("{}", e))?
337            );
338        }
339    }
340
341    if had_errors {
342        return Err(color_eyre::eyre::eyre!("One or more notes could not be found"));
343    }
344
345    Ok(())
346}
347
348#[cfg(test)]
349mod tests {
350    use super::resolve_show_paths;
351    use std::io::Cursor;
352
353    #[test]
354    fn test_resolve_show_paths_uses_args_when_given() {
355        let args = vec!["projects/foo".to_string(), "inbox/bar".to_string()];
356        let result = resolve_show_paths(args.clone(), None::<Cursor<&[u8]>>).unwrap();
357        assert_eq!(result, args);
358    }
359
360    #[test]
361    fn test_resolve_show_paths_reads_from_reader() {
362        let input = b"projects/foo\ninbox/bar\n";
363        let reader = Cursor::new(input.as_ref());
364        let result = resolve_show_paths(vec![], Some(reader)).unwrap();
365        assert_eq!(result, vec!["projects/foo", "inbox/bar"]);
366    }
367
368    #[test]
369    fn test_resolve_show_paths_skips_blank_lines() {
370        let input = b"projects/foo\n\n  \ninbox/bar\n";
371        let reader = Cursor::new(input.as_ref());
372        let result = resolve_show_paths(vec![], Some(reader)).unwrap();
373        assert_eq!(result, vec!["projects/foo", "inbox/bar"]);
374    }
375
376    #[test]
377    fn test_resolve_show_paths_all_blank_stdin_returns_empty() {
378        let input = b"\n  \n\t\n";
379        let reader = Cursor::new(input.as_ref());
380        let result = resolve_show_paths(vec![], Some(reader));
381        assert!(result.is_err());
382        let msg = result.unwrap_err().to_string();
383        assert!(msg.contains("No paths provided"), "got: {}", msg);
384    }
385
386    #[test]
387    fn test_resolve_show_paths_strips_tab_separated_fields() {
388        // kimun notes outputs tab-separated lines: path\ttitle\tsize\ttimestamp
389        let input = b"projects/foo\tFoo Note\t1234\t1700000000\ninbox/bar\tBar\t42\t1700000001\n";
390        let reader = Cursor::new(input.as_ref());
391        let result = resolve_show_paths(vec![], Some(reader)).unwrap();
392        assert_eq!(result, vec!["projects/foo", "inbox/bar"]);
393    }
394
395    #[test]
396    fn test_resolve_show_paths_no_args_no_reader_errors() {
397        let result = resolve_show_paths(vec![], None::<Cursor<&[u8]>>);
398        assert!(result.is_err());
399        let msg = result.unwrap_err().to_string();
400        assert!(msg.contains("No paths provided"), "got: {}", msg);
401    }
402}