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