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