1use anyhow::{Context, Result};
2use schemars::JsonSchema;
3use serde::{Deserialize, Serialize};
4
5mod templates;
6
7use crate::config::validation::{canonical_reference_key, validate_reference_url_https_only};
8use crate::config::{
9 ReferenceEntry, ReferenceMount, RepoConfigManager, RepoMappingManager,
10 extract_org_repo_from_url,
11};
12use crate::git::utils::get_control_repo_root;
13use crate::mount::auto_mount::update_active_mounts;
14use crate::mount::get_mount_manager;
15use crate::platform::detect_platform;
16
17#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
18#[serde(rename_all = "snake_case")]
19pub enum TemplateType {
20 Research,
21 Plan,
22 Requirements,
23 PrDescription,
24}
25
26impl TemplateType {
27 pub fn label(&self) -> &'static str {
28 match self {
29 TemplateType::Research => "research",
30 TemplateType::Plan => "plan",
31 TemplateType::Requirements => "requirements",
32 TemplateType::PrDescription => "pr_description",
33 }
34 }
35 pub fn content(&self) -> &'static str {
36 match self {
37 TemplateType::Research => templates::RESEARCH_TEMPLATE_MD,
38 TemplateType::Plan => templates::PLAN_TEMPLATE_MD,
39 TemplateType::Requirements => templates::REQUIREMENTS_TEMPLATE_MD,
40 TemplateType::PrDescription => templates::PR_DESCRIPTION_TEMPLATE_MD,
41 }
42 }
43 pub fn guidance(&self) -> &'static str {
44 match self {
45 TemplateType::Research => templates::RESEARCH_GUIDANCE,
46 TemplateType::Plan => templates::PLAN_GUIDANCE,
47 TemplateType::Requirements => templates::REQUIREMENTS_GUIDANCE,
48 TemplateType::PrDescription => templates::PR_DESCRIPTION_GUIDANCE,
49 }
50 }
51}
52
53#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
56pub struct ReferenceItem {
57 pub path: String,
58 #[serde(skip_serializing_if = "Option::is_none")]
59 pub description: Option<String>,
60}
61
62#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
63pub struct ReferencesList {
64 pub base: String,
65 pub entries: Vec<ReferenceItem>,
66}
67
68#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
69pub struct AddReferenceOk {
70 pub url: String,
71 pub org: String,
72 pub repo: String,
73 pub mount_path: String,
74 pub mount_target: String,
75 #[serde(skip_serializing_if = "Option::is_none")]
76 pub mapping_path: Option<String>,
77 pub already_existed: bool,
78 pub config_updated: bool,
79 pub cloned: bool,
80 pub mounted: bool,
81 #[serde(default)]
82 pub warnings: Vec<String>,
83}
84
85#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
86pub struct TemplateResponse {
87 pub template_type: TemplateType,
88}
89
90pub async fn add_reference_impl_adapter(
104 url: String,
105 description: Option<String>,
106) -> Result<AddReferenceOk> {
107 let input_url = url.trim().to_string();
108
109 validate_reference_url_https_only(&input_url)
111 .context("invalid input: URL failed HTTPS validation")?;
112
113 let (org, repo) = extract_org_repo_from_url(&input_url)
115 .context("invalid input: failed to extract org/repo from URL")?;
116
117 let repo_root =
119 get_control_repo_root(&std::env::current_dir().context("failed to get current directory")?)
120 .context("failed to get control repo root")?;
121
122 let mgr = RepoConfigManager::new(repo_root.clone());
123 let mut cfg = mgr
124 .ensure_v2_default()
125 .context("failed to ensure v2 config")?;
126
127 let mut existing_keys = std::collections::HashSet::new();
129 for e in &cfg.references {
130 let existing_url = match e {
131 ReferenceEntry::Simple(s) => s.as_str(),
132 ReferenceEntry::WithMetadata(rm) => rm.remote.as_str(),
133 };
134 if let Ok(k) = canonical_reference_key(existing_url) {
135 existing_keys.insert(k);
136 }
137 }
138 let this_key =
139 canonical_reference_key(&input_url).context("invalid input: failed to canonicalize URL")?;
140 let already_existed = existing_keys.contains(&this_key);
141
142 let ds = mgr
144 .load_desired_state()
145 .context("failed to load desired state")?
146 .ok_or_else(|| anyhow::anyhow!("not found: no repository configuration found"))?;
147 let mount_path = format!("{}/{}/{}", ds.mount_dirs.references, org, repo);
148 let mount_target = repo_root
149 .join(".thoughts-data")
150 .join(&mount_path)
151 .to_string_lossy()
152 .to_string();
153
154 let repo_mapping =
156 RepoMappingManager::new().context("failed to create repo mapping manager")?;
157 let pre_mapping = repo_mapping
158 .resolve_url(&input_url)
159 .ok()
160 .flatten()
161 .map(|p| p.to_string_lossy().to_string());
162
163 let mut config_updated = false;
165 let mut warnings: Vec<String> = Vec::new();
166 if !already_existed {
167 if let Some(desc) = description.clone() {
168 cfg.references
169 .push(ReferenceEntry::WithMetadata(ReferenceMount {
170 remote: input_url.clone(),
171 description: if desc.trim().is_empty() {
172 None
173 } else {
174 Some(desc)
175 },
176 }));
177 } else {
178 cfg.references
179 .push(ReferenceEntry::Simple(input_url.clone()));
180 }
181
182 let ws = mgr
183 .save_v2_validated(&cfg)
184 .context("failed to save config")?;
185 warnings.extend(ws);
186 config_updated = true;
187 } else if description.is_some() {
188 warnings.push(
189 "Reference already exists; description was not updated (use CLI to modify metadata)"
190 .to_string(),
191 );
192 }
193
194 if let Err(e) = update_active_mounts().await {
196 warnings.push(format!("Mount synchronization encountered an error: {}", e));
197 }
198
199 let repo_mapping_post =
201 RepoMappingManager::new().context("failed to create repo mapping manager")?;
202 let post_mapping = repo_mapping_post
203 .resolve_url(&input_url)
204 .ok()
205 .flatten()
206 .map(|p| p.to_string_lossy().to_string());
207 let cloned = pre_mapping.is_none() && post_mapping.is_some();
208
209 let platform = detect_platform().context("failed to detect platform")?;
211 let mount_manager = get_mount_manager(&platform).context("failed to get mount manager")?;
212 let active = mount_manager
213 .list_mounts()
214 .await
215 .context("failed to list mounts")?;
216 let target_path = std::path::PathBuf::from(&mount_target);
217 let target_canon = std::fs::canonicalize(&target_path).unwrap_or(target_path.clone());
218 let mut mounted = false;
219 for mi in active {
220 let canon = std::fs::canonicalize(&mi.target).unwrap_or(mi.target.clone());
221 if canon == target_canon {
222 mounted = true;
223 break;
224 }
225 }
226
227 if post_mapping.is_none() {
229 warnings.push(
230 "Repository was not cloned or mapped. It may be private or network unavailable. \
231 You can retry or run 'thoughts references sync' via CLI."
232 .to_string(),
233 );
234 }
235 if !mounted {
236 warnings.push(
237 "Mount is not active. You can retry or run 'thoughts mount update' via CLI."
238 .to_string(),
239 );
240 }
241
242 Ok(AddReferenceOk {
243 url: input_url,
244 org,
245 repo,
246 mount_path,
247 mount_target,
248 mapping_path: post_mapping,
249 already_existed,
250 config_updated,
251 cloned,
252 mounted,
253 warnings,
254 })
255}
256
257#[cfg(test)]
260mod tests {
261 use super::*;
262 use crate::documents::{ActiveDocuments, DocumentInfo, WriteDocumentOk};
263 use crate::utils::human_size;
264 use agentic_tools_core::fmt::{TextFormat, TextOptions};
265
266 #[test]
267 fn test_human_size_formatting() {
268 assert_eq!(human_size(0), "0 B");
269 assert_eq!(human_size(1), "1 B");
270 assert_eq!(human_size(1023), "1023 B");
271 assert_eq!(human_size(1024), "1.0 KB");
272 assert_eq!(human_size(2048), "2.0 KB");
273 assert_eq!(human_size(1024 * 1024), "1.0 MB");
274 assert_eq!(human_size(2 * 1024 * 1024), "2.0 MB");
275 }
276
277 #[test]
278 fn test_write_document_ok_format() {
279 let ok = WriteDocumentOk {
280 path: "./thoughts/feat/research/a.md".into(),
281 bytes_written: 2048,
282 };
283 let text = ok.fmt_text(&TextOptions::default());
284 assert!(text.contains("2.0 KB"));
285 assert!(text.contains("\u{2713} Created")); assert!(text.contains("./thoughts/feat/research/a.md"));
287 }
288
289 #[test]
290 fn test_active_documents_empty() {
291 let docs = ActiveDocuments {
292 base: "./thoughts/x".into(),
293 files: vec![],
294 };
295 let s = docs.fmt_text(&TextOptions::default());
296 assert!(s.contains("<none>"));
297 assert!(s.contains("./thoughts/x"));
298 }
299
300 #[test]
301 fn test_active_documents_with_files() {
302 let docs = ActiveDocuments {
303 base: "./thoughts/feature".into(),
304 files: vec![DocumentInfo {
305 path: "./thoughts/feature/research/test.md".into(),
306 doc_type: "research".into(),
307 size: 1024,
308 modified: "2025-10-15T12:00:00Z".into(),
309 }],
310 };
311 let text = docs.fmt_text(&TextOptions::default());
312 assert!(text.contains("research/test.md"));
313 assert!(text.contains("2025-10-15 12:00 UTC"));
314 }
315
316 #[test]
319 fn test_references_list_empty() {
320 let refs = ReferencesList {
321 base: "references".into(),
322 entries: vec![],
323 };
324 let s = refs.fmt_text(&TextOptions::default());
325 assert!(s.contains("<none>"));
326 assert!(s.contains("references"));
327 }
328
329 #[test]
330 fn test_references_list_without_descriptions() {
331 let refs = ReferencesList {
332 base: "references".into(),
333 entries: vec![
334 ReferenceItem {
335 path: "references/org/repo1".into(),
336 description: None,
337 },
338 ReferenceItem {
339 path: "references/org/repo2".into(),
340 description: None,
341 },
342 ],
343 };
344 let text = refs.fmt_text(&TextOptions::default());
345 assert!(text.contains("org/repo1"));
346 assert!(text.contains("org/repo2"));
347 assert!(!text.contains("\u{2014}")); }
349
350 #[test]
351 fn test_references_list_with_descriptions() {
352 let refs = ReferencesList {
353 base: "references".into(),
354 entries: vec![
355 ReferenceItem {
356 path: "references/org/repo1".into(),
357 description: Some("First repo".into()),
358 },
359 ReferenceItem {
360 path: "references/org/repo2".into(),
361 description: Some("Second repo".into()),
362 },
363 ],
364 };
365 let text = refs.fmt_text(&TextOptions::default());
366 assert!(text.contains("org/repo1 \u{2014} First repo")); assert!(text.contains("org/repo2 \u{2014} Second repo"));
368 }
369
370 #[test]
371 fn test_add_reference_ok_format() {
372 let ok = AddReferenceOk {
373 url: "https://github.com/org/repo".into(),
374 org: "org".into(),
375 repo: "repo".into(),
376 mount_path: "references/org/repo".into(),
377 mount_target: "/abs/.thoughts-data/references/org/repo".into(),
378 mapping_path: Some("/home/user/.thoughts/clones/repo".into()),
379 already_existed: false,
380 config_updated: true,
381 cloned: true,
382 mounted: true,
383 warnings: vec!["note".into()],
384 };
385 let s = ok.fmt_text(&TextOptions::default());
386 assert!(s.contains("\u{2713} Added reference")); assert!(s.contains("Org/Repo: org/repo"));
388 assert!(s.contains("Cloned: true"));
389 assert!(s.contains("Mounted: true"));
390 assert!(s.contains("Warnings:\n- note"));
391 }
392
393 #[test]
394 fn test_add_reference_ok_format_already_existed() {
395 let ok = AddReferenceOk {
396 url: "https://github.com/org/repo".into(),
397 org: "org".into(),
398 repo: "repo".into(),
399 mount_path: "references/org/repo".into(),
400 mount_target: "/abs/.thoughts-data/references/org/repo".into(),
401 mapping_path: Some("/home/user/.thoughts/clones/repo".into()),
402 already_existed: true,
403 config_updated: false,
404 cloned: false,
405 mounted: true,
406 warnings: vec![],
407 };
408 let s = ok.fmt_text(&TextOptions::default());
409 assert!(s.contains("\u{2713} Reference already exists (idempotent)"));
410 assert!(s.contains("Config updated: false"));
411 assert!(!s.contains("Warnings:"));
412 }
413
414 #[test]
415 fn test_add_reference_ok_format_no_mapping() {
416 let ok = AddReferenceOk {
417 url: "https://github.com/org/repo".into(),
418 org: "org".into(),
419 repo: "repo".into(),
420 mount_path: "references/org/repo".into(),
421 mount_target: "/abs/.thoughts-data/references/org/repo".into(),
422 mapping_path: None,
423 already_existed: false,
424 config_updated: true,
425 cloned: false,
426 mounted: false,
427 warnings: vec!["Clone failed".into()],
428 };
429 let s = ok.fmt_text(&TextOptions::default());
430 assert!(s.contains("Mapping: <none>"));
431 assert!(s.contains("Mounted: false"));
432 assert!(s.contains("- Clone failed"));
433 }
434
435 #[test]
436 fn test_template_response_format_research() {
437 let resp = TemplateResponse {
438 template_type: TemplateType::Research,
439 };
440 let s = resp.fmt_text(&TextOptions::default());
441 assert!(s.starts_with("Here is the research template:"));
442 assert!(s.contains("```markdown"));
443 assert!(s.contains("# Research: [Topic]"));
445 assert!(s.contains("Stop. Before writing this document"));
447 }
448
449 #[test]
450 fn test_template_variants_non_empty() {
451 let all = [
452 TemplateType::Research,
453 TemplateType::Plan,
454 TemplateType::Requirements,
455 TemplateType::PrDescription,
456 ];
457 for t in all {
458 assert!(
459 !t.content().trim().is_empty(),
460 "Embedded content unexpectedly empty for {:?}",
461 t
462 );
463 assert!(
464 !t.label().trim().is_empty(),
465 "Label unexpectedly empty for {:?}",
466 t
467 );
468 }
469 }
470}