1use anyhow::{Context, Result};
2use schemars::JsonSchema;
3use serde::{Deserialize, Serialize};
4use std::sync::{Arc, OnceLock};
5use std::time::Duration;
6use tokio::sync::Semaphore;
7
8mod templates;
9
10use crate::config::validation::{
11 canonical_reference_instance_key, validate_pinned_ref_full_name_new_input,
12 validate_reference_url_https_only,
13};
14use crate::config::{
15 ReferenceEntry, ReferenceMount, RepoConfigManager, RepoMappingManager,
16 extract_org_repo_from_url,
17};
18use crate::git::ref_key::encode_ref_key;
19use crate::git::remote_refs::{RemoteRef, discover_remote_refs};
20use crate::git::utils::get_control_repo_root;
21use crate::mount::MountSpace;
22use crate::mount::auto_mount::update_active_mounts;
23use crate::mount::get_mount_manager;
24use crate::platform::detect_platform;
25
26const DEFAULT_REPO_REFS_LIMIT: usize = 100;
27const MAX_REPO_REFS_LIMIT: usize = 200;
28const REPO_REFS_MAX_CONCURRENCY: usize = 4;
29const REPO_REFS_TIMEOUT_SECS: u64 = 20;
30
31static REPO_REFS_SEM: OnceLock<Arc<Semaphore>> = OnceLock::new();
32
33fn find_matching_existing_reference(
34 cfg: &crate::config::RepoConfigV2,
35 input_url: &str,
36 requested_ref_name: Option<&str>,
37) -> Option<(String, Option<String>)> {
38 let wanted = canonical_reference_instance_key(input_url, requested_ref_name).ok()?;
39
40 for entry in &cfg.references {
41 let (existing_url, existing_ref_name) = match entry {
42 ReferenceEntry::Simple(url) => (url.as_str(), None),
43 ReferenceEntry::WithMetadata(reference_mount) => (
44 reference_mount.remote.as_str(),
45 reference_mount.ref_name.as_deref(),
46 ),
47 };
48
49 let Ok(existing_key) = canonical_reference_instance_key(existing_url, existing_ref_name)
50 else {
51 continue;
52 };
53
54 if existing_key == wanted {
55 return Some((
56 existing_url.to_string(),
57 existing_ref_name.map(ToString::to_string),
58 ));
59 }
60 }
61
62 None
63}
64
65#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
66#[serde(rename_all = "snake_case")]
67pub enum TemplateType {
68 Research,
69 Plan,
70 Requirements,
71 PrDescription,
72}
73
74impl TemplateType {
75 pub fn label(&self) -> &'static str {
76 match self {
77 TemplateType::Research => "research",
78 TemplateType::Plan => "plan",
79 TemplateType::Requirements => "requirements",
80 TemplateType::PrDescription => "pr_description",
81 }
82 }
83 pub fn content(&self) -> &'static str {
84 match self {
85 TemplateType::Research => templates::RESEARCH_TEMPLATE_MD,
86 TemplateType::Plan => templates::PLAN_TEMPLATE_MD,
87 TemplateType::Requirements => templates::REQUIREMENTS_TEMPLATE_MD,
88 TemplateType::PrDescription => templates::PR_DESCRIPTION_TEMPLATE_MD,
89 }
90 }
91 pub fn guidance(&self) -> &'static str {
92 match self {
93 TemplateType::Research => templates::RESEARCH_GUIDANCE,
94 TemplateType::Plan => templates::PLAN_GUIDANCE,
95 TemplateType::Requirements => templates::REQUIREMENTS_GUIDANCE,
96 TemplateType::PrDescription => templates::PR_DESCRIPTION_GUIDANCE,
97 }
98 }
99}
100
101#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
104pub struct ReferenceItem {
105 pub path: String,
106 #[serde(skip_serializing_if = "Option::is_none")]
107 pub description: Option<String>,
108}
109
110#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
111pub struct ReferencesList {
112 pub base: String,
113 pub entries: Vec<ReferenceItem>,
114}
115
116#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
117pub struct RepoRefsList {
118 pub url: String,
119 pub total: usize,
120 pub truncated: bool,
121 pub entries: Vec<RemoteRef>,
122}
123
124#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
125pub struct AddReferenceOk {
126 pub url: String,
127 #[serde(rename = "ref", skip_serializing_if = "Option::is_none")]
128 pub ref_name: Option<String>,
129 pub org: String,
130 pub repo: String,
131 pub mount_path: String,
132 pub mount_target: String,
133 #[serde(skip_serializing_if = "Option::is_none")]
134 pub mapping_path: Option<String>,
135 pub already_existed: bool,
136 pub config_updated: bool,
137 pub cloned: bool,
138 pub mounted: bool,
139 #[serde(default)]
140 pub warnings: Vec<String>,
141}
142
143#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
144pub struct TemplateResponse {
145 pub template_type: TemplateType,
146}
147
148fn repo_refs_semaphore() -> Arc<Semaphore> {
151 REPO_REFS_SEM
152 .get_or_init(|| Arc::new(Semaphore::new(REPO_REFS_MAX_CONCURRENCY)))
153 .clone()
154}
155
156fn get_repo_refs_blocking(input_url: String, limit: usize) -> Result<RepoRefsList> {
157 let repo_root =
158 get_control_repo_root(&std::env::current_dir().context("failed to get current directory")?)
159 .context("failed to get control repo root")?;
160 let mut refs = discover_remote_refs(&repo_root, &input_url)?;
161 refs.sort_by(|a, b| {
162 a.name
163 .cmp(&b.name)
164 .then_with(|| a.target.cmp(&b.target))
165 .then_with(|| a.oid.cmp(&b.oid))
166 .then_with(|| a.peeled.cmp(&b.peeled))
167 });
168
169 let total = refs.len();
170 let truncated = total > limit;
171 refs.truncate(limit);
172
173 Ok(RepoRefsList {
174 url: input_url,
175 total,
176 truncated,
177 entries: refs,
178 })
179}
180
181async fn run_blocking_repo_refs_with_deadline<R, F>(
182 sem: Arc<Semaphore>,
183 timeout: Duration,
184 op_label: String,
185 work: F,
186) -> Result<R>
187where
188 R: Send + 'static,
189 F: FnOnce() -> Result<R> + Send + 'static,
190{
191 let deadline = tokio::time::Instant::now() + timeout;
192
193 let permit = match tokio::time::timeout_at(deadline, sem.acquire_owned()).await {
194 Ok(permit) => permit.expect("semaphore closed"),
195 Err(_) => anyhow::bail!("timeout while waiting to start {op_label} after {timeout:?}"),
196 };
197
198 let mut handle = tokio::task::spawn_blocking(move || {
199 let _permit = permit;
200 work()
201 });
202
203 match tokio::time::timeout_at(deadline, &mut handle).await {
204 Ok(joined) => joined.context("remote ref discovery task failed")?,
205 Err(_) => {
206 tokio::spawn(async move {
207 let _ = handle.await;
208 });
209 anyhow::bail!("timeout while {op_label} after {timeout:?}");
210 }
211 }
212}
213
214pub async fn get_repo_refs_impl_adapter(url: String, limit: Option<usize>) -> Result<RepoRefsList> {
215 let input_url = url.trim().to_string();
216 validate_reference_url_https_only(&input_url)
217 .context("invalid input: URL failed HTTPS validation")?;
218 let limit = normalize_repo_ref_limit(limit)?;
219
220 let sem = repo_refs_semaphore();
221 let timeout = Duration::from_secs(REPO_REFS_TIMEOUT_SECS);
222 let op_label = format!("discovering remote refs for {}", input_url);
223 let url_for_task = input_url.clone();
224
225 run_blocking_repo_refs_with_deadline(sem, timeout, op_label, move || {
226 get_repo_refs_blocking(url_for_task, limit)
227 })
228 .await
229}
230
231fn normalize_repo_ref_limit(limit: Option<usize>) -> Result<usize> {
232 match limit.unwrap_or(DEFAULT_REPO_REFS_LIMIT) {
233 0 => anyhow::bail!("invalid input: limit must be at least 1"),
234 limit if limit > MAX_REPO_REFS_LIMIT => anyhow::bail!(
235 "invalid input: limit must be at most {}",
236 MAX_REPO_REFS_LIMIT
237 ),
238 limit => Ok(limit),
239 }
240}
241
242fn response_identity_url<'a>(
243 input_url: &'a str,
244 matched_existing: Option<&'a (String, Option<String>)>,
245) -> &'a str {
246 matched_existing
247 .map(|(stored_url, _)| stored_url.as_str())
248 .unwrap_or(input_url)
249}
250
251pub async fn add_reference_impl_adapter(
264 url: String,
265 description: Option<String>,
266 ref_name: Option<String>,
267) -> Result<AddReferenceOk> {
268 let input_url = url.trim().to_string();
269 let requested_ref_name = match ref_name {
270 Some(ref_name) => {
271 let trimmed = ref_name.trim();
272 if trimmed.is_empty() {
273 anyhow::bail!("invalid input: ref cannot be empty");
274 }
275 Some(trimmed.to_string())
276 }
277 None => None,
278 };
279 if let Some(ref_name) = requested_ref_name.as_deref()
280 && let Err(e) = validate_pinned_ref_full_name_new_input(ref_name)
281 {
282 anyhow::bail!(
283 "invalid input: ref must be a full ref name like 'refs/heads/main' or 'refs/tags/v1.2.3' \
284(shorthand like 'main' is not supported). Details: {}. \
285Tip: call thoughts_get_repo_refs to discover full refs.",
286 e
287 );
288 }
289
290 validate_reference_url_https_only(&input_url)
292 .context("invalid input: URL failed HTTPS validation")?;
293
294 let repo_root =
296 get_control_repo_root(&std::env::current_dir().context("failed to get current directory")?)
297 .context("failed to get control repo root")?;
298
299 let mgr = RepoConfigManager::new(repo_root.clone());
300 let mut cfg = mgr
301 .ensure_v2_default()
302 .context("failed to ensure v2 config")?;
303
304 canonical_reference_instance_key(&input_url, requested_ref_name.as_deref())
305 .context("invalid input: failed to canonicalize URL")?;
306 let matched_existing =
307 find_matching_existing_reference(&cfg, &input_url, requested_ref_name.as_deref());
308 let already_existed = matched_existing.is_some();
309 let effective_ref_name = matched_existing
310 .as_ref()
311 .and_then(|(_, ref_name)| ref_name.clone())
312 .or_else(|| requested_ref_name.clone());
313 let identity_url = response_identity_url(&input_url, matched_existing.as_ref());
314 let (org, repo) = extract_org_repo_from_url(identity_url)
315 .context("invalid input: failed to extract org/repo from URL")?;
316 let ref_key = effective_ref_name
317 .as_deref()
318 .map(encode_ref_key)
319 .transpose()?;
320
321 let ds = mgr
323 .load_desired_state()
324 .context("failed to load desired state")?
325 .ok_or_else(|| anyhow::anyhow!("not found: no repository configuration found"))?;
326 let mount_space = MountSpace::Reference {
327 org_path: org.clone(),
328 repo: repo.clone(),
329 ref_key: ref_key.clone(),
330 };
331 let mount_path = mount_space.relative_path(&ds.mount_dirs);
332 let mount_target = repo_root
333 .join(".thoughts-data")
334 .join(&mount_path)
335 .to_string_lossy()
336 .to_string();
337
338 let repo_mapping =
340 RepoMappingManager::new().context("failed to create repo mapping manager")?;
341 let pre_mapping = repo_mapping
342 .resolve_reference_url(&input_url, effective_ref_name.as_deref())
343 .ok()
344 .flatten()
345 .map(|p| p.to_string_lossy().to_string());
346
347 let mut config_updated = false;
349 let mut warnings: Vec<String> = Vec::new();
350 let description = description.and_then(|desc| {
351 let trimmed = desc.trim();
352 (!trimmed.is_empty()).then(|| trimmed.to_string())
353 });
354 if !already_existed {
355 if description.is_some() || requested_ref_name.is_some() {
356 cfg.references
357 .push(ReferenceEntry::WithMetadata(ReferenceMount {
358 remote: input_url.clone(),
359 description: description.clone(),
360 ref_name: requested_ref_name.clone(),
361 }));
362 } else {
363 cfg.references
364 .push(ReferenceEntry::Simple(input_url.clone()));
365 }
366
367 let ws = mgr
368 .save_v2_validated(&cfg)
369 .context("failed to save config")?;
370 warnings.extend(ws);
371 config_updated = true;
372 } else if description.is_some() || requested_ref_name.is_some() {
373 warnings.push(
374 "Reference already exists; metadata was not updated (use CLI to modify metadata)"
375 .to_string(),
376 );
377 }
378
379 if let Err(e) = update_active_mounts().await {
381 warnings.push(format!("Mount synchronization encountered an error: {}", e));
382 }
383
384 let repo_mapping_post =
386 RepoMappingManager::new().context("failed to create repo mapping manager")?;
387 let post_mapping = repo_mapping_post
388 .resolve_reference_url(&input_url, effective_ref_name.as_deref())
389 .ok()
390 .flatten()
391 .map(|p| p.to_string_lossy().to_string());
392 let cloned = pre_mapping.is_none() && post_mapping.is_some();
393
394 let platform = detect_platform().context("failed to detect platform")?;
396 let mount_manager = get_mount_manager(&platform).context("failed to get mount manager")?;
397 let active = mount_manager
398 .list_mounts()
399 .await
400 .context("failed to list mounts")?;
401 let target_path = std::path::PathBuf::from(&mount_target);
402 let target_canon = std::fs::canonicalize(&target_path).unwrap_or(target_path.clone());
403 let mut mounted = false;
404 for mi in active {
405 let canon = std::fs::canonicalize(&mi.target).unwrap_or(mi.target.clone());
406 if canon == target_canon {
407 mounted = true;
408 break;
409 }
410 }
411
412 if post_mapping.is_none() {
414 warnings.push(
415 "Repository was not cloned or mapped. It may be private or network unavailable. \
416 You can retry or run 'thoughts references sync' via CLI."
417 .to_string(),
418 );
419 }
420 if !mounted {
421 warnings.push(
422 "Mount is not active. You can retry or run 'thoughts mount update' via CLI."
423 .to_string(),
424 );
425 }
426
427 Ok(AddReferenceOk {
428 url: input_url,
429 ref_name: effective_ref_name,
430 org,
431 repo,
432 mount_path,
433 mount_target,
434 mapping_path: post_mapping,
435 already_existed,
436 config_updated,
437 cloned,
438 mounted,
439 warnings,
440 })
441}
442
443#[cfg(test)]
446mod tests {
447 use super::*;
448 use crate::config::{MountDirsV2, RepoConfigV2};
449 use crate::documents::{ActiveDocuments, DocumentInfo, WriteDocumentOk};
450 use crate::utils::human_size;
451 use agentic_tools_core::fmt::{TextFormat, TextOptions};
452 use std::sync::atomic::{AtomicBool, Ordering};
453 use std::sync::mpsc;
454
455 fn sample_remote_ref(name: &str) -> RemoteRef {
456 RemoteRef {
457 name: name.to_string(),
458 oid: Some("abc123".to_string()),
459 peeled: None,
460 target: None,
461 }
462 }
463
464 #[test]
465 fn normalize_repo_ref_limit_defaults_and_validates() {
466 assert_eq!(normalize_repo_ref_limit(None).unwrap(), 100);
467 assert_eq!(normalize_repo_ref_limit(Some(1)).unwrap(), 1);
468 assert!(normalize_repo_ref_limit(Some(0)).is_err());
469 assert!(normalize_repo_ref_limit(Some(201)).is_err());
470 }
471
472 #[test]
473 fn test_human_size_formatting() {
474 assert_eq!(human_size(0), "0 B");
475 assert_eq!(human_size(1), "1 B");
476 assert_eq!(human_size(1023), "1023 B");
477 assert_eq!(human_size(1024), "1.0 KB");
478 assert_eq!(human_size(2048), "2.0 KB");
479 assert_eq!(human_size(1024 * 1024), "1.0 MB");
480 assert_eq!(human_size(2 * 1024 * 1024), "2.0 MB");
481 }
482
483 #[test]
484 fn test_write_document_ok_format() {
485 let ok = WriteDocumentOk {
486 path: "./thoughts/feat/research/a.md".into(),
487 bytes_written: 2048,
488 };
489 let text = ok.fmt_text(&TextOptions::default());
490 assert!(text.contains("2.0 KB"));
491 assert!(text.contains("\u{2713} Created")); assert!(text.contains("./thoughts/feat/research/a.md"));
493 }
494
495 #[test]
496 fn test_active_documents_empty() {
497 let docs = ActiveDocuments {
498 base: "./thoughts/x".into(),
499 files: vec![],
500 };
501 let s = docs.fmt_text(&TextOptions::default());
502 assert!(s.contains("<none>"));
503 assert!(s.contains("./thoughts/x"));
504 }
505
506 #[test]
507 fn test_active_documents_with_files() {
508 let docs = ActiveDocuments {
509 base: "./thoughts/feature".into(),
510 files: vec![DocumentInfo {
511 path: "./thoughts/feature/research/test.md".into(),
512 doc_type: "research".into(),
513 size: 1024,
514 modified: "2025-10-15T12:00:00Z".into(),
515 }],
516 };
517 let text = docs.fmt_text(&TextOptions::default());
518 assert!(text.contains("research/test.md"));
519 assert!(text.contains("2025-10-15 12:00 UTC"));
520 }
521
522 #[test]
525 fn test_references_list_empty() {
526 let refs = ReferencesList {
527 base: "references".into(),
528 entries: vec![],
529 };
530 let s = refs.fmt_text(&TextOptions::default());
531 assert!(s.contains("<none>"));
532 assert!(s.contains("references"));
533 }
534
535 #[test]
536 fn test_references_list_without_descriptions() {
537 let refs = ReferencesList {
538 base: "references".into(),
539 entries: vec![
540 ReferenceItem {
541 path: "references/org/repo1".into(),
542 description: None,
543 },
544 ReferenceItem {
545 path: "references/org/repo2".into(),
546 description: None,
547 },
548 ],
549 };
550 let text = refs.fmt_text(&TextOptions::default());
551 assert!(text.contains("org/repo1"));
552 assert!(text.contains("org/repo2"));
553 assert!(!text.contains("\u{2014}")); }
555
556 #[test]
557 fn test_references_list_with_descriptions() {
558 let refs = ReferencesList {
559 base: "references".into(),
560 entries: vec![
561 ReferenceItem {
562 path: "references/org/repo1".into(),
563 description: Some("First repo".into()),
564 },
565 ReferenceItem {
566 path: "references/org/repo2".into(),
567 description: Some("Second repo".into()),
568 },
569 ],
570 };
571 let text = refs.fmt_text(&TextOptions::default());
572 assert!(text.contains("org/repo1 \u{2014} First repo")); assert!(text.contains("org/repo2 \u{2014} Second repo"));
574 }
575
576 #[test]
577 fn test_repo_ref_sorting_is_deterministic() {
578 let mut refs = [
579 sample_remote_ref("refs/tags/v2"),
580 sample_remote_ref("refs/heads/main"),
581 ];
582 refs.sort_by(|a, b| {
583 a.name
584 .cmp(&b.name)
585 .then_with(|| a.target.cmp(&b.target))
586 .then_with(|| a.oid.cmp(&b.oid))
587 .then_with(|| a.peeled.cmp(&b.peeled))
588 });
589 assert_eq!(refs[0].name, "refs/heads/main");
590 assert_eq!(refs[1].name, "refs/tags/v2");
591 }
592
593 #[tokio::test]
594 async fn get_repo_refs_rejects_invalid_limit_async() {
595 let err = get_repo_refs_impl_adapter("https://github.com/org/repo".into(), Some(0))
596 .await
597 .unwrap_err();
598 assert!(err.to_string().contains("limit must be at least 1"));
599 }
600
601 #[tokio::test]
602 async fn get_repo_refs_rejects_ssh_url_async() {
603 let err = get_repo_refs_impl_adapter("git@github.com:org/repo.git".into(), None)
604 .await
605 .unwrap_err();
606 assert!(
607 format!("{err:#}").to_lowercase().contains("ssh"),
608 "unexpected error chain: {err:#}"
609 );
610 }
611
612 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
613 async fn repo_refs_deadline_includes_semaphore_acquire_time() {
614 let sem = Arc::new(Semaphore::new(1));
615 let _held = sem.clone().acquire_owned().await.unwrap();
616 let work_started = Arc::new(AtomicBool::new(false));
617 let started = work_started.clone();
618
619 let err = run_blocking_repo_refs_with_deadline(
620 sem,
621 Duration::from_millis(10),
622 "test operation".to_string(),
623 move || {
624 started.store(true, Ordering::SeqCst);
625 Ok(())
626 },
627 )
628 .await
629 .unwrap_err();
630
631 assert!(err.to_string().contains("waiting to start test operation"));
632 assert!(!work_started.load(Ordering::SeqCst));
633 }
634
635 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
636 async fn repo_refs_timeout_retains_permit_until_blocking_work_finishes() {
637 let sem = Arc::new(Semaphore::new(1));
638 let (started_tx, started_rx) = mpsc::channel();
639 let (release_tx, release_rx) = mpsc::channel();
640 let (finished_tx, finished_rx) = mpsc::channel();
641
642 let timed_out = tokio::spawn(run_blocking_repo_refs_with_deadline(
643 sem.clone(),
644 Duration::from_millis(20),
645 "test operation".to_string(),
646 move || {
647 started_tx.send(()).unwrap();
648 release_rx.recv().unwrap();
649 finished_tx.send(()).unwrap();
650 Ok(())
651 },
652 ));
653
654 started_rx
655 .recv_timeout(Duration::from_secs(1))
656 .expect("blocking work should have started");
657
658 let err = timed_out.await.unwrap().unwrap_err();
659 assert!(err.to_string().contains("timeout while test operation"));
660 assert_eq!(sem.available_permits(), 0, "permit should still be held");
661
662 let blocked_err = run_blocking_repo_refs_with_deadline(
663 sem.clone(),
664 Duration::from_millis(10),
665 "follow-up operation".to_string(),
666 || Ok(()),
667 )
668 .await
669 .unwrap_err();
670 assert!(
671 blocked_err
672 .to_string()
673 .contains("waiting to start follow-up operation"),
674 "unexpected error: {blocked_err:#}"
675 );
676
677 release_tx.send(()).unwrap();
678 finished_rx
679 .recv_timeout(Duration::from_secs(1))
680 .expect("blocking work should finish after release");
681
682 for _ in 0..20 {
683 if sem.available_permits() == 1 {
684 break;
685 }
686 tokio::time::sleep(Duration::from_millis(10)).await;
687 }
688
689 assert_eq!(
690 sem.available_permits(),
691 1,
692 "permit should be released after blocking work completes"
693 );
694
695 run_blocking_repo_refs_with_deadline(
696 sem,
697 Duration::from_secs(1),
698 "final operation".to_string(),
699 || Ok(()),
700 )
701 .await
702 .expect("follow-up work should succeed once permit is released");
703 }
704
705 #[test]
706 fn test_repo_refs_list_format() {
707 let refs = RepoRefsList {
708 url: "https://github.com/org/repo".into(),
709 total: 2,
710 truncated: false,
711 entries: vec![
712 RemoteRef {
713 name: "refs/heads/main".into(),
714 oid: Some("abc123".into()),
715 peeled: None,
716 target: None,
717 },
718 RemoteRef {
719 name: "refs/tags/v1.0.0".into(),
720 oid: Some("def456".into()),
721 peeled: Some("fedcba".into()),
722 target: None,
723 },
724 ],
725 };
726
727 let text = refs.fmt_text(&TextOptions::default());
728 assert!(text.contains("Remote refs for https://github.com/org/repo"));
729 assert!(text.contains("refs/heads/main"));
730 assert!(text.contains("oid=abc123"));
731 assert!(text.contains("peeled=fedcba"));
732 }
733
734 #[test]
735 fn test_add_reference_ok_format() {
736 let ok = AddReferenceOk {
737 url: "https://github.com/org/repo".into(),
738 ref_name: Some("refs/heads/main".into()),
739 org: "org".into(),
740 repo: "repo".into(),
741 mount_path: "references/org/repo".into(),
742 mount_target: "/abs/.thoughts-data/references/org/repo".into(),
743 mapping_path: Some("/home/user/.thoughts/clones/repo".into()),
744 already_existed: false,
745 config_updated: true,
746 cloned: true,
747 mounted: true,
748 warnings: vec!["note".into()],
749 };
750 let s = ok.fmt_text(&TextOptions::default());
751 assert!(s.contains("\u{2713} Added reference")); assert!(s.contains("Org/Repo: org/repo"));
753 assert!(s.contains("Ref: refs/heads/main"));
754 assert!(s.contains("Cloned: true"));
755 assert!(s.contains("Mounted: true"));
756 assert!(s.contains("Warnings:\n- note"));
757 }
758
759 #[test]
760 fn test_add_reference_ok_format_already_existed() {
761 let ok = AddReferenceOk {
762 url: "https://github.com/org/repo".into(),
763 ref_name: None,
764 org: "org".into(),
765 repo: "repo".into(),
766 mount_path: "references/org/repo".into(),
767 mount_target: "/abs/.thoughts-data/references/org/repo".into(),
768 mapping_path: Some("/home/user/.thoughts/clones/repo".into()),
769 already_existed: true,
770 config_updated: false,
771 cloned: false,
772 mounted: true,
773 warnings: vec![],
774 };
775 let s = ok.fmt_text(&TextOptions::default());
776 assert!(s.contains("\u{2713} Reference already exists (idempotent)"));
777 assert!(s.contains("Config updated: false"));
778 assert!(!s.contains("Warnings:"));
779 }
780
781 #[test]
782 fn test_add_reference_ok_format_no_mapping() {
783 let ok = AddReferenceOk {
784 url: "https://github.com/org/repo".into(),
785 ref_name: None,
786 org: "org".into(),
787 repo: "repo".into(),
788 mount_path: "references/org/repo".into(),
789 mount_target: "/abs/.thoughts-data/references/org/repo".into(),
790 mapping_path: None,
791 already_existed: false,
792 config_updated: true,
793 cloned: false,
794 mounted: false,
795 warnings: vec!["Clone failed".into()],
796 };
797 let s = ok.fmt_text(&TextOptions::default());
798 assert!(s.contains("Mapping: <none>"));
799 assert!(s.contains("Mounted: false"));
800 assert!(s.contains("- Clone failed"));
801 }
802
803 #[tokio::test]
804 async fn add_reference_rejects_shorthand_ref_early() {
805 let err = add_reference_impl_adapter(
806 "https://github.com/org/repo".into(),
807 None,
808 Some("main".into()),
809 )
810 .await
811 .unwrap_err();
812
813 assert!(
814 err.to_string()
815 .contains("invalid input: ref must be a full ref name")
816 );
817 }
818
819 #[tokio::test]
820 async fn add_reference_rejects_refs_remotes_early() {
821 let err = add_reference_impl_adapter(
822 "https://github.com/org/repo".into(),
823 None,
824 Some("refs/remotes/origin/main".into()),
825 )
826 .await
827 .unwrap_err();
828
829 assert!(
830 err.to_string()
831 .contains("invalid input: ref must be a full ref name"),
832 "unexpected error: {err:#}"
833 );
834 assert!(
835 err.to_string().contains("refs/heads/main"),
836 "unexpected error: {err:#}"
837 );
838 }
839
840 #[tokio::test]
841 async fn add_reference_rejects_bare_heads_prefix_early() {
842 let err = add_reference_impl_adapter(
843 "https://github.com/org/repo".into(),
844 None,
845 Some("refs/heads/".into()),
846 )
847 .await
848 .unwrap_err();
849
850 assert!(
851 err.to_string()
852 .contains("invalid input: ref must be a full ref name")
853 );
854 }
855
856 #[tokio::test]
857 async fn add_reference_rejects_bare_tags_prefix_early() {
858 let err = add_reference_impl_adapter(
859 "https://github.com/org/repo".into(),
860 None,
861 Some("refs/tags/".into()),
862 )
863 .await
864 .unwrap_err();
865
866 assert!(
867 err.to_string()
868 .contains("invalid input: ref must be a full ref name")
869 );
870 }
871
872 #[test]
873 fn find_matching_existing_reference_returns_legacy_ref_name_when_equivalent() {
874 let cfg = RepoConfigV2 {
875 version: "2.0".into(),
876 mount_dirs: MountDirsV2::default(),
877 thoughts_mount: None,
878 context_mounts: vec![],
879 references: vec![ReferenceEntry::WithMetadata(ReferenceMount {
880 remote: "https://github.com/org/repo".into(),
881 description: None,
882 ref_name: Some("refs/remotes/origin/main".into()),
883 })],
884 };
885
886 let found = find_matching_existing_reference(
887 &cfg,
888 "https://github.com/org/repo",
889 Some("refs/heads/main"),
890 )
891 .expect("should match by canonical identity");
892
893 assert_eq!(found.0, "https://github.com/org/repo");
894 assert_eq!(found.1.as_deref(), Some("refs/remotes/origin/main"));
895 }
896
897 #[test]
898 fn idempotent_add_reference_response_uses_matched_stored_url_identity_for_paths() {
899 let input_url = "https://github.com/org/repo";
900 let stored_url = "https://github.com/Org/Repo";
901 let matched_existing = Some((stored_url.to_string(), None));
902
903 let identity_url = response_identity_url(input_url, matched_existing.as_ref());
904 assert_eq!(identity_url, stored_url);
905
906 let (org, repo) = extract_org_repo_from_url(identity_url).unwrap();
907 let mount_dirs = MountDirsV2::default();
908 let mount_space = MountSpace::Reference {
909 org_path: org.clone(),
910 repo: repo.clone(),
911 ref_key: None,
912 };
913
914 assert_eq!(org, "Org");
915 assert_eq!(repo, "Repo");
916 assert_eq!(
917 mount_space.relative_path(&mount_dirs),
918 format!("{}/{}/{}", mount_dirs.references, org, repo)
919 );
920 }
921
922 #[test]
923 fn test_template_response_format_research() {
924 let resp = TemplateResponse {
925 template_type: TemplateType::Research,
926 };
927 let s = resp.fmt_text(&TextOptions::default());
928 assert!(s.starts_with("Here is the research template:"));
929 assert!(s.contains("```markdown"));
930 assert!(s.contains("# Research: [Topic]"));
932 assert!(s.contains("Stop. Before writing this document"));
934 }
935
936 #[test]
937 fn test_template_variants_non_empty() {
938 let all = [
939 TemplateType::Research,
940 TemplateType::Plan,
941 TemplateType::Requirements,
942 TemplateType::PrDescription,
943 ];
944 for t in all {
945 assert!(
946 !t.content().trim().is_empty(),
947 "Embedded content unexpectedly empty for {:?}",
948 t
949 );
950 assert!(
951 !t.label().trim().is_empty(),
952 "Label unexpectedly empty for {:?}",
953 t
954 );
955 }
956 }
957}