Skip to main content

thoughts_tool/mcp/
mod.rs

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