1use kimun_core::nfs::VaultPath;
6use kimun_core::nfs::filename::note_name_from_title;
7
8use super::{Turn, citations};
9
10pub fn suggested_path(question: &str) -> VaultPath {
12 VaultPath::new("ask").append(&VaultPath::note_path_from(note_name_from_title(question)))
13}
14
15pub fn citation_names(turn: &Turn) -> Vec<String> {
23 let max = turn.sources.iter().map(|s| s.ordinal).max().unwrap_or(0);
24 let mut names = vec![String::new(); max];
25 for s in &turn.sources {
26 if (1..=max).contains(&s.ordinal) {
27 names[s.ordinal - 1] = s.path.get_clean_name();
28 }
29 }
30 names
31}
32
33pub fn note_content(turn: &Turn) -> String {
43 let linked = citations::link_sources(&turn.answer, &citation_names(turn));
46
47 let mut seen = Vec::new();
48 for s in &turn.sources {
49 let name = s.path.get_clean_name();
50 if !seen.contains(&name) {
51 seen.push(name);
52 }
53 }
54
55 let mut out = format!("# {}\n\n{}\n\n## Sources\n", turn.question, linked);
56 for name in &seen {
57 out.push_str(&format!("- [[{name}]]\n"));
58 }
59 out
60}
61
62#[cfg(test)]
63mod tests {
64 use super::*;
65 use crate::ask::{AskSource, TurnStatus};
66
67 fn turn_with(question: &str, answer: &str, sources: Vec<AskSource>) -> Turn {
68 let sources = sources
72 .into_iter()
73 .enumerate()
74 .map(|(i, mut s)| {
75 if s.ordinal == 0 {
76 s.ordinal = i + 1;
77 }
78 s
79 })
80 .collect();
81 Turn {
82 id: 0,
83 question: question.to_string(),
84 answer: answer.to_string(),
85 sources,
86 status: TurnStatus::Done,
87 }
88 }
89
90 fn source(path: &str, heading: &str) -> AskSource {
91 source_ord(path, heading, 0)
92 }
93
94 fn source_ord(path: &str, heading: &str, ordinal: usize) -> AskSource {
97 AskSource {
98 path: VaultPath::new(path),
99 heading: heading.to_string(),
100 date: None,
101 score: 1.0,
102 text: String::new(),
103 ordinal,
104 }
105 }
106
107 #[test]
108 fn note_content_links_citations_and_lists_sources() {
109 let turn = turn_with(
110 "Why kimün?",
111 "Because notes [1]. And general knowledge.",
112 vec![source("projects/kimun.md", "intro")],
113 );
114 let body = note_content(&turn);
115 assert!(body.starts_with("# Why kimün?\n"));
116 assert!(body.contains("Because notes [[kimun]]."));
117 assert!(body.contains("## Sources"));
118 assert!(body.contains("- [[kimun]]"));
119 }
120
121 #[test]
122 fn note_content_lists_distinct_sources_in_first_seen_order() {
123 let turn = turn_with(
124 "q",
125 "a [1] b [2] c [1]",
126 vec![
127 source("projects/alpha.md", "h1"),
128 source("projects/beta.md", "h2"),
129 ],
130 );
131 let body = note_content(&turn);
132 let sources_section = body.split("## Sources").nth(1).unwrap();
133 let alpha_pos = sources_section.find("[[alpha]]").unwrap();
134 let beta_pos = sources_section.find("[[beta]]").unwrap();
135 assert!(alpha_pos < beta_pos);
136 assert_eq!(sources_section.matches("[[alpha]]").count(), 1);
137 }
138
139 #[test]
140 fn note_content_lists_uncited_sources_too() {
141 let turn = turn_with(
142 "q",
143 "only cites the first source [1]",
144 vec![
145 source("projects/alpha.md", "h1"),
146 source("projects/beta.md", "h2"),
147 ],
148 );
149 let body = note_content(&turn);
150 let sources_section = body.split("## Sources").nth(1).unwrap();
151 assert!(sources_section.contains("[[alpha]]"));
152 assert!(
153 sources_section.contains("[[beta]]"),
154 "an uncited source must still appear in the footer: {sources_section:?}"
155 );
156 }
157
158 #[test]
159 fn note_content_dedupes_sources_sharing_a_clean_name() {
160 let turn = turn_with(
161 "q",
162 "cites nothing in particular",
163 vec![source("a/note.md", "h1"), source("b/note.md", "h2")],
164 );
165 let body = note_content(&turn);
166 let sources_section = body.split("## Sources").nth(1).unwrap();
167 assert_eq!(sources_section.matches("[[note]]").count(), 1);
168 }
169
170 #[test]
171 fn citations_link_by_ordinal_even_when_sources_are_shuffled() {
172 let turn = turn_with(
175 "q",
176 "first [1] second [2] third [3]",
177 vec![
178 source_ord("projects/charlie.md", "h", 3),
179 source_ord("projects/alpha.md", "h", 1),
180 source_ord("projects/beta.md", "h", 2),
181 ],
182 );
183 let body = note_content(&turn);
184 assert!(
185 body.contains("first [[alpha]]"),
186 "`[1]` → ordinal-1 source: {body}"
187 );
188 assert!(
189 body.contains("second [[beta]]"),
190 "`[2]` → ordinal-2 source: {body}"
191 );
192 assert!(
193 body.contains("third [[charlie]]"),
194 "`[3]` → ordinal-3 source: {body}"
195 );
196 }
197
198 #[test]
199 fn a_gap_leaves_the_citation_marker_untouched() {
200 let turn = turn_with(
203 "q",
204 "a [1] b [2] c [3]",
205 vec![
206 source_ord("projects/alpha.md", "h", 1),
207 source_ord("projects/charlie.md", "h", 3),
208 ],
209 );
210 let body = note_content(&turn);
211 assert!(body.contains("a [[alpha]]"), "{body}");
212 assert!(
213 body.contains("b [2] c"),
214 "gap `[2]` stays a literal marker: {body}"
215 );
216 assert!(body.contains("[[charlie]]"), "{body}");
217 }
218
219 #[test]
220 fn suggested_path_nests_under_ask_and_slugs_the_question() {
221 let path = suggested_path("How do I Ship v2?");
222 assert_eq!(path.to_string(), "ask/how-do-i-ship-v2.md");
223 }
224
225 #[tokio::test]
231 async fn suggested_path_can_actually_be_saved_as_a_note() {
232 use kimun_core::{NoteVault, VaultConfig};
233
234 let dir = tempfile::TempDir::new().unwrap();
235 let vault = NoteVault::new(VaultConfig::new(crate::test_support::sys(dir.path())))
236 .await
237 .unwrap();
238 vault.validate_and_init().await.unwrap();
239
240 let path = suggested_path("David Howell (sometimes refered as David H)?");
241 vault.create_note(&path, "content").await.unwrap();
242 }
243}