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