1use 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 {
16 path: String,
18 content: Option<String>,
20 },
21 Append {
23 path: String,
25 content: Option<String>,
27 },
28 Quick {
30 content: Option<String>,
32 },
33 Triage,
35 Show {
37 paths: Vec<String>,
39 #[arg(long, value_enum, default_value = "text")]
40 format: crate::cli::output::OutputFormat,
41 },
42 Overwrite {
44 path: String,
46 content: Option<String>,
48 #[arg(long)]
50 force: bool,
51 },
52 Replace {
54 path: String,
56 old: String,
58 new: String,
60 #[arg(long)]
62 all: bool,
63 #[arg(long)]
65 regex: bool,
66 #[arg(long)]
68 preview: bool,
69 },
70 Delete {
72 path: String,
74 #[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 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 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
290fn 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 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 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 = ¬e_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, 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 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 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}