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