Skip to main content

thoughts_tool/mcp/
mod.rs

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// Data types for MCP tools (formatting implementations in src/fmt.rs)
106
107#[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
152// Note: Tool implementations are in thoughts-mcp-tools crate using agentic-tools framework.
153
154fn 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
255/// Public adapter for add_reference implementation.
256///
257/// This function is callable by agentic-tools wrappers. It contains the actual
258/// logic for adding a GitHub repository as a reference.
259///
260/// # Arguments
261/// * `url` - HTTPS GitHub URL (https://github.com/org/repo or .git) or generic https://*.git clone URL
262/// * `description` - Optional description for why this reference was added
263/// * `ref_name` - Optional full git ref name (for example refs/heads/main)
264///
265/// # Returns
266/// `AddReferenceOk` on success, `anyhow::Error` on failure.
267pub 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 URL per MCP HTTPS-only rules
295    validate_reference_url_https_only(&input_url)
296        .context("invalid input: URL failed HTTPS validation")?;
297
298    // Resolve repo root and config manager
299    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    // Compute paths for response
326    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    // Capture pre-sync mapping status
343    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    // Update config if new
352    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    // Always attempt to sync clone+mount (best-effort, no rollback)
384    if let Err(e) = update_active_mounts().await {
385        warnings.push(format!("Mount synchronization encountered an error: {}", e));
386    }
387
388    // Post-sync mapping status to infer cloning
389    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    // Determine mounted by listing active mounts
399    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    // Additional warnings for visibility
417    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// Note: MCP server implementation moved to thoughts-mcp-tools crate using agentic-tools framework.
448
449#[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")); // ✓
502        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    // Note: DocumentType serde tests are in crate::documents::tests
533
534    #[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}")); // No em-dash separator
564    }
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")); // em-dash
583        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")); // ✓
762        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        // spot-check content from the research template
941        assert!(s.contains("# Research: [Topic]"));
942        // research guidance presence
943        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}