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            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// 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    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
249/// Public adapter for `add_reference` implementation.
250///
251/// This function is callable by agentic-tools wrappers. It contains the actual
252/// logic for adding a GitHub repository as a reference.
253///
254/// # Arguments
255/// * `url` - HTTPS GitHub URL (<https://github.com/org/repo> or .git) or generic https://*.git clone URL
256/// * `description` - Optional description for why this reference was added
257/// * `ref_name` - Optional full git ref name (for example refs/heads/main)
258///
259/// # Returns
260/// `AddReferenceOk` on success, `anyhow::Error` on failure.
261pub 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 URL per MCP HTTPS-only rules
311    validate_reference_url_https_only(&input_url)
312        .context("invalid input: URL failed HTTPS validation")?;
313
314    // Resolve repo root and config manager
315    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    // Compute paths for response
342    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    // Capture pre-sync mapping status
359    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    // Update config if new
368    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    // Always attempt to sync clone+mount (best-effort, no rollback)
400    if let Err(e) = update_active_mounts().await {
401        warnings.push(format!("Mount synchronization encountered an error: {e}"));
402    }
403
404    // Post-sync mapping status to infer cloning
405    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    // Determine mounted by listing active mounts
415    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    // Additional warnings for visibility
433    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// Note: MCP server implementation moved to thoughts-mcp-tools crate using agentic-tools framework.
464
465#[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")); // ✓
518        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    // Note: DocumentType serde tests are in crate::documents::tests
549
550    #[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}")); // No em-dash separator
580    }
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")); // em-dash
599        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")); // ✓
778        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        // spot-check content from the research template
961        assert!(s.contains("# Research: [Topic]"));
962        // research guidance presence
963        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}