1mod git;
8
9use std::{collections::VecDeque, path::PathBuf, process::Stdio, sync::LazyLock};
10
11use regex::Regex;
12use serde::Deserialize;
13use tokio::process::Command;
14
15pub use git::{derive_git_notebook_name, git_rev_parse};
16
17static ANSI_REGEX: LazyLock<Regex> =
24 LazyLock::new(|| Regex::new(r"\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~]|[ -/]*[0-~])").unwrap());
25
26fn strip_ansi(text: &str) -> String {
28 ANSI_REGEX.replace_all(text, "").into_owned()
29}
30
31#[derive(Debug, thiserror::Error)]
33pub enum NbError {
34 #[error("nb command failed: {0}")]
35 CommandFailed(String),
36
37 #[error(
38 "nb not found in PATH; install via: brew install xwmx/taps/nb (macOS) or see https://github.com/xwmx/nb#installation"
39 )]
40 NotFound,
41
42 #[error("IO error: {0}")]
43 Io(#[from] std::io::Error),
44}
45
46#[derive(Clone, Debug)]
51pub struct Config {
52 pub notebook: Option<String>,
54 pub create_notebook: bool,
56 pub allow_top_level_notes: bool,
58 pub disable_git_signing: bool,
60}
61
62impl Default for Config {
63 fn default() -> Self {
64 Self {
65 notebook: None,
66 create_notebook: true,
67 allow_top_level_notes: false,
68 disable_git_signing: false,
69 }
70 }
71}
72
73#[derive(Clone)]
75pub struct NbClient {
76 default_notebook: Option<String>,
78 create_notebook: bool,
80 disable_git_signing: bool,
82 allow_top_level_notes: bool,
84}
85
86const FOLDER_REQUIRED_MESSAGE: &str = "This server is configured to require `folder` for new notes. Use the `nb.mkdir` tool to create new folders and the `nb.folders` tool to list existing folders.";
87
88const NOTEBOOK_FIELD_MESSAGE: &str = "Invalid `notebook`: use a bare notebook name only. Use `folder` for folder paths and `id`/`selector` for note selectors.";
89
90const FOLDER_FIELD_MESSAGE: &str = "Invalid folder path: use `folder` for folder paths only, not notebook-qualified selectors. To choose a notebook, use the separate `notebook` field.";
91
92#[derive(Debug, Clone, Copy, Default, Deserialize, PartialEq, Eq)]
94#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
95#[serde(rename_all = "lowercase")]
96pub enum EditMode {
97 #[default]
99 Replace,
100 Append,
102 Prepend,
104}
105
106#[derive(Debug, Clone, Copy, Default, Deserialize, PartialEq, Eq)]
108#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
109#[serde(rename_all = "lowercase")]
110pub enum SearchMode {
111 #[default]
113 Any,
114 All,
116}
117
118#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)]
120#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
121#[serde(rename_all = "lowercase")]
122pub enum TaskStatus {
123 Open,
125 Closed,
127}
128
129impl NbClient {
130 pub fn new(config: &Config) -> anyhow::Result<Self> {
136 let default_notebook = config
137 .notebook
138 .as_deref()
139 .map(String::from)
140 .or_else(derive_git_notebook_name);
141 Ok(Self {
142 default_notebook,
143 create_notebook: config.create_notebook,
144 disable_git_signing: config.disable_git_signing,
145 allow_top_level_notes: config.allow_top_level_notes,
146 })
147 }
148
149 fn require_folder_for_new_note(&self, folder: Option<&str>) -> Result<(), NbError> {
150 if self.allow_top_level_notes || folder.is_some_and(|value| !value.trim().is_empty()) {
151 return Ok(());
152 }
153 Err(NbError::CommandFailed(FOLDER_REQUIRED_MESSAGE.to_string()))
154 }
155
156 async fn resolve_target_selector(
157 &self,
158 id: &str,
159 notebook: Option<&str>,
160 ) -> Result<(String, String), NbError> {
161 if let Some((embedded_notebook, path)) = parse_qualified_selector(id)? {
162 let notebook = match notebook {
163 Some(value) => {
164 validate_notebook_name(value)?;
165 if value != embedded_notebook {
166 return Err(NbError::CommandFailed(format!(
167 "ambiguous selector: id targets notebook `{embedded_notebook}`, but notebook field is `{value}`"
168 )));
169 }
170 embedded_notebook.to_string()
171 }
172 _ => embedded_notebook.to_string(),
173 };
174 self.ensure_existing_notebook(¬ebook).await?;
175 return Ok((notebook, format!("{}:{}", embedded_notebook, path)));
176 }
177 let notebook = self.resolve_notebook(notebook).await?;
178 Ok((notebook.clone(), format!("{}:{}", notebook, id)))
179 }
180
181 fn append_notebook_warning(&self, output: String, notebook: &str) -> String {
182 let Some(default_notebook) = self.default_notebook.as_deref() else {
183 return output;
184 };
185 if default_notebook == notebook {
186 return output;
187 }
188 append_warning(
189 output,
190 format!(
191 "Warning: wrote to notebook `{notebook}`, not the project default notebook `{default_notebook}`. If this was unintended, move or delete the note and retry with the correct notebook/folder."
192 ),
193 )
194 }
195
196 fn resolve_notebook_name(&self, notebook: Option<&str>) -> Result<String, NbError> {
198 if let Some(name) = notebook {
199 validate_notebook_name(name)?;
200 return Ok(name.to_string());
201 }
202 if let Some(name) = self.default_notebook.as_deref() {
203 validate_notebook_name(name)?;
204 return Ok(name.to_string());
205 }
206 Err(NbError::CommandFailed(
207 "notebook not configured; set --notebook or NB_MCP_NOTEBOOK".to_string(),
208 ))
209 }
210
211 async fn resolve_notebook(&self, notebook: Option<&str>) -> Result<String, NbError> {
212 let name = self.resolve_notebook_name(notebook)?;
213 self.ensure_notebook(&name).await?;
214 Ok(name)
215 }
216
217 async fn ensure_notebook(&self, notebook: &str) -> Result<(), NbError> {
218 match self.check_notebook(notebook).await {
219 Ok(()) => Ok(()),
220 Err(_) => {
221 if !self.create_notebook {
222 return Err(NbError::CommandFailed(format!(
223 "notebook not found; create it with the nb CLI (`nb notebooks add {}`) \
224 or remove --no-create-notebook",
225 notebook
226 )));
227 }
228 self.exec_vec(vec![
229 "notebooks".to_string(),
230 "add".to_string(),
231 notebook.to_string(),
232 ])
233 .await?;
234 Ok(())
235 }
236 }
237 }
238
239 async fn ensure_existing_notebook(&self, notebook: &str) -> Result<(), NbError> {
240 self.check_notebook(notebook).await.map_err(|_| {
241 NbError::CommandFailed(format!(
242 "notebook not found: `{notebook}`. Use a copied selector only for an existing notebook."
243 ))
244 })
245 }
246
247 async fn check_notebook(&self, notebook: &str) -> Result<(), NbError> {
248 let show_result = self
249 .exec_vec(vec![
250 "notebooks".to_string(),
251 "show".to_string(),
252 notebook.to_string(),
253 "--path".to_string(),
254 ])
255 .await;
256 match show_result {
257 Ok(output) => {
258 if output.trim().is_empty() {
259 return Err(NbError::CommandFailed(
260 "nb notebooks path output was empty".to_string(),
261 ));
262 }
263 Ok(())
264 }
265 Err(_) => Err(NbError::CommandFailed(format!(
266 "notebook not found: `{notebook}`"
267 ))),
268 }
269 }
270
271 async fn exec(&self, args: &[&str]) -> Result<String, NbError> {
273 tracing::debug!(?args, "executing nb command");
274 let mut command = Command::new("nb");
275 command
276 .args(args)
277 .stdin(Stdio::null()) .stdout(Stdio::piped())
279 .stderr(Stdio::piped());
280 if self.disable_git_signing {
281 apply_git_signing_env(&mut command);
282 }
283 let output = command
284 .spawn()
285 .map_err(|e| {
286 if e.kind() == std::io::ErrorKind::NotFound {
287 NbError::NotFound
288 } else {
289 NbError::Io(e)
290 }
291 })?
292 .wait_with_output()
293 .await?;
294
295 if output.status.success() {
296 let stdout = String::from_utf8_lossy(&output.stdout);
297 Ok(strip_ansi(&stdout))
298 } else {
299 let stderr = String::from_utf8_lossy(&output.stderr);
300 let stdout = String::from_utf8_lossy(&output.stdout);
301 let msg = if stderr.is_empty() {
303 strip_ansi(&stdout)
304 } else {
305 strip_ansi(&stderr)
306 };
307 Err(NbError::CommandFailed(msg))
308 }
309 }
310
311 async fn exec_vec(&self, args: Vec<String>) -> Result<String, NbError> {
313 let args_ref: Vec<&str> = args.iter().map(|s| s.as_str()).collect();
314 self.exec(&args_ref).await
315 }
316
317 pub async fn status(&self, notebook: Option<&str>) -> Result<String, NbError> {
319 let notebook = self.resolve_notebook(notebook).await?;
320 self.exec_vec(vec![format!("{}:", notebook), "status".to_string()])
321 .await
322 }
323
324 pub async fn notebooks(&self) -> Result<String, NbError> {
326 self.exec(&["notebooks", "--no-color"]).await
328 }
329
330 pub async fn notebook_path(&self, notebook: Option<&str>) -> Result<PathBuf, NbError> {
332 let notebook = self.resolve_notebook(notebook).await?;
333 let output = self
334 .exec_vec(vec![
335 "notebooks".to_string(),
336 "show".to_string(),
337 notebook,
338 "--path".to_string(),
339 ])
340 .await?;
341 let path = output.trim();
342 if path.is_empty() {
343 return Err(NbError::CommandFailed(
344 "nb notebooks path output was empty".to_string(),
345 ));
346 }
347 Ok(PathBuf::from(path))
348 }
349
350 pub async fn add(
352 &self,
353 title: Option<&str>,
354 content: &str,
355 tags: &[String],
356 folder: Option<&str>,
357 notebook: Option<&str>,
358 ) -> Result<String, NbError> {
359 let mut args = Vec::new();
360 self.require_folder_for_new_note(folder)?;
361 validate_folder_option(folder)?;
362
363 let notebook = self.resolve_notebook(notebook).await?;
364 let cmd = format!("{}:add", notebook);
365 args.push(cmd);
366
367 if let Some(t) = title {
369 args.push("--title".to_string());
370 args.push(t.to_string());
371 }
372
373 args.push("--content".to_string());
375 args.push(content.to_string());
376
377 for tag in tags {
379 args.push("--tags".to_string());
380 let tag_str = if tag.starts_with('#') {
381 tag.clone()
382 } else {
383 format!("#{}", tag)
384 };
385 args.push(tag_str);
386 }
387
388 if let Some(f) = folder {
390 args.push("--folder".to_string());
391 args.push(f.to_string());
392 }
393
394 self.exec_vec(args)
395 .await
396 .map(|output| self.append_notebook_warning(output, ¬ebook))
397 }
398
399 pub async fn show(&self, id: &str, notebook: Option<&str>) -> Result<String, NbError> {
401 let (_, selector) = self.resolve_target_selector(id, notebook).await?;
402 self.exec_vec(vec!["show".to_string(), selector, "--no-color".to_string()])
403 .await
404 }
405
406 pub async fn list(
408 &self,
409 folder: Option<&str>,
410 tags: &[String],
411 limit: Option<u32>,
412 notebook: Option<&str>,
413 ) -> Result<String, NbError> {
414 let mut args = Vec::new();
415 validate_folder_option(folder)?;
416
417 let notebook = self.resolve_notebook(notebook).await?;
418 let cmd = match folder {
419 Some(f) => format!("{}:{}/", notebook, f),
420 None => format!("{}:", notebook),
421 };
422
423 args.push("list".to_string());
424 args.push(cmd);
425
426 args.push("--no-color".to_string());
428
429 if let Some(n) = limit {
431 args.push("-n".to_string());
432 args.push(n.to_string());
433 }
434
435 for tag in tags {
437 args.push("--tags".to_string());
438 let tag_str = if tag.starts_with('#') {
439 tag.clone()
440 } else {
441 format!("#{}", tag)
442 };
443 args.push(tag_str);
444 }
445
446 self.exec_vec(args).await
447 }
448
449 pub async fn search(
451 &self,
452 queries: &[String],
453 mode: SearchMode,
454 tags: &[String],
455 folder: Option<&str>,
456 notebook: Option<&str>,
457 ) -> Result<String, NbError> {
458 validate_folder_option(folder)?;
459 if queries.is_empty() {
460 return Err(NbError::CommandFailed(
461 "at least one search query is required".to_string(),
462 ));
463 }
464
465 let notebook = self.resolve_notebook(notebook).await?;
466 let scope = match folder {
467 Some(f) => format!("{}:{}/", notebook, f),
468 None => format!("{}:", notebook),
469 };
470 let args = search_command_args(scope, queries, mode, tags);
471 self.exec_vec(args).await
472 }
473
474 pub async fn edit(
476 &self,
477 id: &str,
478 content: &str,
479 mode: EditMode,
480 notebook: Option<&str>,
481 ) -> Result<String, NbError> {
482 let (notebook, selector) = self.resolve_target_selector(id, notebook).await?;
483 let output = self.exec_vec(edit_args(selector, content, mode)).await?;
484 Ok(self.append_notebook_warning(output, ¬ebook))
485 }
486
487 pub async fn delete(&self, id: &str, notebook: Option<&str>) -> Result<String, NbError> {
489 let (notebook, selector) = self.resolve_target_selector(id, notebook).await?;
490 let output = self
491 .exec_vec(vec!["delete".to_string(), selector, "--force".to_string()])
492 .await?;
493 Ok(self.append_notebook_warning(output, ¬ebook))
494 }
495
496 pub async fn move_note(
498 &self,
499 id: &str,
500 destination: &str,
501 notebook: Option<&str>,
502 ) -> Result<String, NbError> {
503 validate_destination(destination)?;
504 let (notebook, selector) = self.resolve_target_selector(id, notebook).await?;
505 let output = self
506 .exec_vec(vec![
507 "move".to_string(),
508 selector,
509 destination.to_string(),
510 "--force".to_string(),
511 ])
512 .await?;
513 Ok(self.append_notebook_warning(output, ¬ebook))
514 }
515
516 pub async fn todo(
518 &self,
519 title: &str,
520 description: Option<&str>,
521 tasks: &[String],
522 tags: &[String],
523 folder: Option<&str>,
524 notebook: Option<&str>,
525 ) -> Result<String, NbError> {
526 self.require_folder_for_new_note(folder)?;
527 validate_folder_option(folder)?;
528 let notebook = self.resolve_notebook(notebook).await?;
529 let output = self
530 .exec_vec(todo_command_args(
531 ¬ebook,
532 title,
533 description,
534 tasks,
535 tags,
536 folder,
537 ))
538 .await?;
539 Ok(self.append_notebook_warning(output, ¬ebook))
540 }
541
542 pub async fn do_task(
544 &self,
545 id: &str,
546 task_number: Option<u32>,
547 notebook: Option<&str>,
548 ) -> Result<String, NbError> {
549 let (notebook, selector) = self.resolve_target_selector(id, notebook).await?;
550 let output = self
551 .exec_vec(task_command_args("do", selector, task_number))
552 .await?;
553 Ok(self.append_notebook_warning(output, ¬ebook))
554 }
555
556 pub async fn undo_task(
558 &self,
559 id: &str,
560 task_number: Option<u32>,
561 notebook: Option<&str>,
562 ) -> Result<String, NbError> {
563 let (notebook, selector) = self.resolve_target_selector(id, notebook).await?;
564 let output = self
565 .exec_vec(task_command_args("undo", selector, task_number))
566 .await?;
567 Ok(self.append_notebook_warning(output, ¬ebook))
568 }
569
570 pub async fn tasks(
572 &self,
573 folder: Option<&str>,
574 status: Option<TaskStatus>,
575 recursive: bool,
576 notebook: Option<&str>,
577 ) -> Result<String, NbError> {
578 validate_folder_option(folder)?;
579 let notebook = self.resolve_notebook(notebook).await?;
580 let folder = folder.map(normalize_folder);
581 let scopes = if recursive {
582 self.tasks_scopes_recursive(¬ebook, folder.as_deref())
583 .await?
584 } else {
585 vec![tasks_scope(¬ebook, folder.as_deref())]
586 };
587
588 let mut outputs: Vec<String> = Vec::new();
589 let mut saw_empty = false;
590 for scope in scopes {
591 match self.exec_vec(tasks_command_args(scope, status)).await {
592 Ok(output) => {
593 let output = output.trim();
594 if !output.is_empty() {
595 outputs.push(output.to_string());
596 }
597 }
598 Err(NbError::CommandFailed(message)) if is_empty_tasks_error(&message) => {
599 saw_empty = true;
600 }
601 Err(err) => return Err(err),
602 }
603 }
604 if outputs.is_empty() && saw_empty {
605 return Err(NbError::CommandFailed(empty_tasks_message(status)));
606 }
607 Ok(outputs.join("\n"))
608 }
609
610 async fn tasks_scopes_recursive(
611 &self,
612 notebook: &str,
613 folder: Option<&str>,
614 ) -> Result<Vec<String>, NbError> {
615 let notebook_root = self.notebook_path(Some(notebook)).await?;
616 let start = folder.unwrap_or_default().to_string();
617 let mut queue = VecDeque::new();
618 queue.push_back(start.clone());
619
620 let mut scopes = vec![tasks_scope(notebook, folder)];
621 while let Some(current) = queue.pop_front() {
622 let base = if current.is_empty() {
623 notebook_root.clone()
624 } else {
625 notebook_root.join(¤t)
626 };
627 let children = child_folder_names(&base)?;
628 for child in children {
629 let next = if current.is_empty() {
630 child
631 } else {
632 format!("{}/{}", current, child)
633 };
634 scopes.push(tasks_scope(notebook, Some(&next)));
635 queue.push_back(next);
636 }
637 }
638 Ok(scopes)
639 }
640
641 pub async fn bookmark(
643 &self,
644 url: &str,
645 title: Option<&str>,
646 tags: &[String],
647 comment: Option<&str>,
648 folder: Option<&str>,
649 notebook: Option<&str>,
650 ) -> Result<String, NbError> {
651 let mut args = Vec::new();
652 self.require_folder_for_new_note(folder)?;
653 validate_folder_option(folder)?;
654
655 let notebook = self.resolve_notebook(notebook).await?;
657 let dest = match folder {
658 Some(f) => format!("{}:{}/", notebook, f),
659 None => format!("{}:", notebook),
660 };
661
662 let cmd = format!("{}bookmark", dest);
663 args.push(cmd);
664 args.push(url.to_string());
665
666 if let Some(t) = title {
667 args.push("--title".to_string());
668 args.push(t.to_string());
669 }
670
671 if let Some(c) = comment {
672 args.push("--comment".to_string());
673 args.push(c.to_string());
674 }
675
676 for tag in tags {
677 args.push("--tags".to_string());
678 let tag_str = if tag.starts_with('#') {
679 tag.clone()
680 } else {
681 format!("#{}", tag)
682 };
683 args.push(tag_str);
684 }
685
686 self.exec_vec(args)
687 .await
688 .map(|output| self.append_notebook_warning(output, ¬ebook))
689 }
690
691 pub async fn folders(
693 &self,
694 parent: Option<&str>,
695 notebook: Option<&str>,
696 ) -> Result<String, NbError> {
697 let mut args = vec!["list".to_string()];
698 validate_folder_option(parent)?;
699
700 let notebook = self.resolve_notebook(notebook).await?;
701 let path = match parent {
702 Some(p) => format!("{}:{}/", notebook, p),
703 None => format!("{}:", notebook),
704 };
705 args.push(path);
706
707 args.push("--type".to_string());
709 args.push("folder".to_string());
710 args.push("--no-color".to_string());
711
712 self.exec_vec(args).await
713 }
714
715 pub async fn mkdir(&self, path: &str, notebook: Option<&str>) -> Result<String, NbError> {
717 validate_folder_path(path)?;
718 let notebook = self.resolve_notebook(notebook).await?;
719 let folder_path = mkdir_selector(¬ebook, path);
720 let output = self
721 .exec_vec(vec!["add".to_string(), "folder".to_string(), folder_path])
722 .await?;
723 Ok(self.append_notebook_warning(output, ¬ebook))
724 }
725
726 pub async fn import(
728 &self,
729 source: &str,
730 folder: Option<&str>,
731 filename: Option<&str>,
732 convert: bool,
733 notebook: Option<&str>,
734 ) -> Result<String, NbError> {
735 let mut args = Vec::new();
736 self.require_folder_for_new_note(folder)?;
737 validate_folder_option(folder)?;
738
739 let notebook = self.resolve_notebook(notebook).await?;
740 let cmd = format!("{}:import", notebook);
741 args.push(cmd);
742
743 args.push(source.to_string());
745
746 if convert {
748 args.push("--convert".to_string());
749 }
750
751 if folder.is_some() || filename.is_some() {
754 let dest = match (folder, filename) {
755 (Some(f), Some(n)) => format!("{}/{}", f, n),
756 (Some(f), None) => format!("{}/", f),
757 (None, Some(n)) => n.to_string(),
758 (None, None) => unreachable!(),
759 };
760 args.push(dest);
761 }
762
763 self.exec_vec(args)
764 .await
765 .map(|output| self.append_notebook_warning(output, ¬ebook))
766 }
767}
768
769fn append_warning(mut output: String, warning: String) -> String {
770 if !output.trim().is_empty() {
771 if !output.ends_with('\n') {
772 output.push('\n');
773 }
774 output.push('\n');
775 }
776 output.push_str(&warning);
777 output
778}
779
780fn validate_notebook_name(name: &str) -> Result<(), NbError> {
781 if name.trim().is_empty() || name.contains(':') || name.contains('/') || name.contains('\\') {
782 return Err(NbError::CommandFailed(NOTEBOOK_FIELD_MESSAGE.to_string()));
783 }
784 Ok(())
785}
786
787fn validate_folder_option(folder: Option<&str>) -> Result<(), NbError> {
788 if let Some(path) = folder {
789 validate_folder_path(path)?;
790 }
791 Ok(())
792}
793
794fn validate_folder_path(path: &str) -> Result<(), NbError> {
795 if path.trim().is_empty() || path.contains(':') {
796 return Err(NbError::CommandFailed(FOLDER_FIELD_MESSAGE.to_string()));
797 }
798 Ok(())
799}
800
801fn validate_destination(destination: &str) -> Result<(), NbError> {
802 if destination.trim().is_empty() || destination.contains(':') {
803 return Err(NbError::CommandFailed(
804 "Invalid destination: use a folder path or filename only, not a notebook-qualified selector."
805 .to_string(),
806 ));
807 }
808 Ok(())
809}
810
811fn parse_qualified_selector(selector: &str) -> Result<Option<(&str, &str)>, NbError> {
812 let Some((notebook, path)) = selector.split_once(':') else {
813 return Ok(None);
814 };
815 validate_notebook_name(notebook)?;
816 if path.trim().is_empty() || path.contains(':') {
817 return Err(NbError::CommandFailed(
818 "Invalid selector: use at most one notebook qualifier, as `<notebook>:<folder>/<id>`."
819 .to_string(),
820 ));
821 }
822 Ok(Some((notebook, path)))
823}
824
825fn edit_args(selector: String, content: &str, mode: EditMode) -> Vec<String> {
826 let mut args = vec!["edit".to_string(), selector];
827 match mode {
828 EditMode::Replace => args.push("--overwrite".to_string()),
829 EditMode::Append => {}
830 EditMode::Prepend => args.push("--prepend".to_string()),
831 }
832 args.push("--content".to_string());
833 args.push(content.to_string());
834 args
835}
836
837fn task_command_args(action: &str, selector: String, task_number: Option<u32>) -> Vec<String> {
838 let mut args = vec![action.to_string(), selector];
839 if let Some(number) = task_number {
840 args.push(number.to_string());
841 }
842 args
843}
844
845fn todo_command_args(
846 notebook: &str,
847 title: &str,
848 description: Option<&str>,
849 tasks: &[String],
850 tags: &[String],
851 folder: Option<&str>,
852) -> Vec<String> {
853 let mut args = vec![format!("{notebook}:todo"), "add".to_string()];
854
855 if let Some(folder) = folder {
857 args.push(folder_scope(folder));
858 }
859
860 args.push(title.to_string());
861
862 if let Some(description) = description {
863 args.push("--description".to_string());
864 args.push(description.to_string());
865 }
866
867 for task in tasks {
868 args.push("--task".to_string());
869 args.push(task.to_string());
870 }
871
872 for tag in tags {
873 args.push("--tags".to_string());
874 args.push(normalize_tag(tag));
875 }
876
877 args
878}
879
880fn folder_scope(folder: &str) -> String {
881 if folder.ends_with('/') {
882 folder.to_string()
883 } else {
884 format!("{folder}/")
885 }
886}
887
888fn normalize_tag(tag: &str) -> String {
889 if tag.starts_with('#') {
890 tag.to_string()
891 } else {
892 format!("#{tag}")
893 }
894}
895
896fn normalize_folder(folder: &str) -> String {
897 folder.trim_matches('/').to_string()
898}
899
900fn mkdir_selector(notebook: &str, path: &str) -> String {
901 let normalized = normalize_folder(path);
902 format!("{}:{}", notebook, normalized)
903}
904
905fn tasks_scope(notebook: &str, folder: Option<&str>) -> String {
906 match folder {
907 Some(path) if !path.is_empty() => format!("{}:{}/", notebook, path),
908 _ => format!("{}:", notebook),
909 }
910}
911
912fn tasks_command_args(scope: String, status: Option<TaskStatus>) -> Vec<String> {
913 let mut args = vec!["tasks".to_string(), scope];
914 if let Some(filter) = status {
915 let status = match filter {
916 TaskStatus::Open => "open",
917 TaskStatus::Closed => "closed",
918 };
919 args.push(status.to_string());
920 }
921 args.push("--no-color".to_string());
922 args
923}
924
925fn search_command_args(
926 scope: String,
927 queries: &[String],
928 mode: SearchMode,
929 tags: &[String],
930) -> Vec<String> {
931 let mut args = vec!["search".to_string(), scope];
932 let mut terms = queries.iter();
933 if let Some(first) = terms.next() {
934 args.push(first.to_string());
935 }
936 match mode {
937 SearchMode::Any => {
938 for query in terms {
939 args.push("--or".to_string());
940 args.push(query.to_string());
941 }
942 }
943 SearchMode::All => {
944 for query in terms {
945 args.push(query.to_string());
946 }
947 }
948 }
949 for tag in tags {
950 args.push("--tag".to_string());
951 args.push(normalize_tag(tag));
952 }
953 args.push("--no-color".to_string());
954 args
955}
956
957fn is_empty_tasks_error(message: &str) -> bool {
958 message.trim_start().starts_with("! 0 ") && message.contains(" tasks.")
959}
960
961fn empty_tasks_message(status: Option<TaskStatus>) -> String {
962 match status {
963 Some(TaskStatus::Open) => "! 0 open tasks.".to_string(),
964 Some(TaskStatus::Closed) => "! 0 closed tasks.".to_string(),
965 None => "! 0 tasks.".to_string(),
966 }
967}
968
969fn child_folder_names(path: &std::path::Path) -> Result<Vec<String>, NbError> {
970 let read_dir = match std::fs::read_dir(path) {
971 Ok(entries) => entries,
972 Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
973 Err(err) => return Err(NbError::Io(err)),
974 };
975
976 let mut names = Vec::new();
977 for entry in read_dir {
978 let entry = entry?;
979 let Some(name) = entry.file_name().to_str().map(|value| value.to_string()) else {
980 continue;
981 };
982 if name.starts_with('.') {
983 continue;
984 }
985 let meta = match entry.metadata() {
986 Ok(meta) => meta,
987 Err(err) if err.kind() == std::io::ErrorKind::NotFound => continue,
988 Err(err) => return Err(NbError::Io(err)),
989 };
990 if meta.is_dir() {
991 names.push(name);
992 }
993 }
994 names.sort();
995 Ok(names)
996}
997
998const GIT_SIGNING_OVERRIDES: [(&str, &str); 2] =
999 [("commit.gpgsign", "false"), ("tag.gpgsign", "false")];
1000
1001fn git_config_count(raw: Option<&str>) -> usize {
1002 raw.and_then(|value| value.parse::<usize>().ok())
1003 .unwrap_or(0)
1004}
1005
1006fn git_signing_env_vars(start_index: usize) -> Vec<(String, String)> {
1007 let total = start_index.saturating_add(GIT_SIGNING_OVERRIDES.len());
1008 let mut env_vars = Vec::with_capacity(1 + GIT_SIGNING_OVERRIDES.len() * 2);
1009 env_vars.push(("GIT_CONFIG_COUNT".to_string(), total.to_string()));
1010 for (offset, (key, value)) in GIT_SIGNING_OVERRIDES.iter().enumerate() {
1011 let index = start_index + offset;
1012 env_vars.push((format!("GIT_CONFIG_KEY_{index}"), (*key).to_string()));
1013 env_vars.push((format!("GIT_CONFIG_VALUE_{index}"), (*value).to_string()));
1014 }
1015 env_vars
1016}
1017
1018fn apply_git_signing_env(command: &mut Command) {
1019 let start_index = git_config_count(std::env::var("GIT_CONFIG_COUNT").ok().as_deref());
1020 for (name, value) in git_signing_env_vars(start_index) {
1021 command.env(name, value);
1022 }
1023}