Skip to main content

thoughts_tool/config/
validation.rs

1use crate::repo_identity::{RepoIdentity, parse_url_and_subpath};
2use anyhow::{Result, bail};
3
4/// Sanitize a mount name for use as directory name
5pub fn sanitize_mount_name(name: &str) -> String {
6    name.chars()
7        .map(|c| match c {
8            'a'..='z' | 'A'..='Z' | '0'..='9' | '-' | '_' => c,
9            _ => '_',
10        })
11        .collect()
12}
13
14/// Return true if string looks like a git URL we support
15pub fn is_git_url(s: &str) -> bool {
16    let s = s.trim();
17    s.starts_with("git@")
18        || s.starts_with("https://")
19        || s.starts_with("http://")
20        || s.starts_with("ssh://")
21}
22
23/// Extract host from SSH/HTTPS URLs
24pub fn get_host_from_url(url: &str) -> Result<String> {
25    let (base, _) = parse_url_and_subpath(url);
26    let id = RepoIdentity::parse(&base).map_err(|e| {
27        anyhow::anyhow!(
28            "Unsupported URL (cannot parse host): {}\nDetails: {}",
29            url,
30            e
31        )
32    })?;
33    Ok(id.host)
34}
35
36/// Validate that a reference URL is well-formed and points to org/repo (repo-level only)
37pub fn validate_reference_url(url: &str) -> Result<()> {
38    let url = url.trim();
39    let (base, subpath) = parse_url_and_subpath(url);
40    if subpath.is_some() {
41        bail!(
42            "Cannot add URL with subpath as a reference: {}\n\n\
43             References are repo-level only.\n\
44             Try one of:\n\
45               - Add the repository URL without a subpath\n\
46               - Use 'thoughts mount add <local-subdir>' for subdirectory mounts",
47            url
48        );
49    }
50    if !is_git_url(&base) {
51        bail!(
52            "Invalid reference value: {}\n\n\
53             Must be a git URL using one of:\n  - git@host:org/repo(.git)\n  - https://host/org/repo(.git)\n  - ssh://user@host[:port]/org/repo(.git)\n",
54            url
55        );
56    }
57    // Ensure org/repo structure is parseable via RepoIdentity
58    RepoIdentity::parse(&base).map_err(|e| {
59        anyhow::anyhow!(
60            "Invalid repository URL: {}\n\n\
61             Expected a URL with an org and repo (e.g., github.com/org/repo).\n\
62             Details: {}",
63            url,
64            e
65        )
66    })?;
67    Ok(())
68}
69
70/// Canonical key (host, org_path, repo) all lowercased, without .git
71pub fn canonical_reference_key(url: &str) -> Result<(String, String, String)> {
72    let (base, _) = parse_url_and_subpath(url);
73    let key = RepoIdentity::parse(&base)?.canonical_key();
74    Ok((key.host, key.org_path, key.repo))
75}
76
77// --- MCP HTTPS-only validation helpers ---
78
79/// True if the URL uses SSH schemes we do not support in MCP
80pub fn is_ssh_url(s: &str) -> bool {
81    let s = s.trim();
82    s.starts_with("git@") || s.starts_with("ssh://")
83}
84
85/// True if URL starts with https://
86pub fn is_https_url(s: &str) -> bool {
87    s.trim_start().to_lowercase().starts_with("https://")
88}
89
90/// Validate MCP add_reference input:
91/// - Reject SSH and http://
92/// - Reject subpaths
93/// - Accept GitHub web or clone URLs (https://github.com/org/repo[.git])
94/// - Accept generic https://*.git clone URLs
95pub fn validate_reference_url_https_only(url: &str) -> Result<()> {
96    let url = url.trim();
97
98    // Reject subpaths (URL:subpath)
99    let (base, subpath) = parse_url_and_subpath(url);
100    if subpath.is_some() {
101        bail!(
102            "Cannot add URL with subpath as a reference: {}\n\nReferences are repo-level only.",
103            url
104        );
105    }
106
107    if is_ssh_url(&base) {
108        bail!(
109            "SSH URLs are not supported by the MCP add_reference tool: {}\n\n\
110             Please provide an HTTPS URL, e.g.:\n  https://github.com/org/repo(.git)\n\n\
111             If you must use SSH, run the CLI instead:\n  thoughts references add <git@... or ssh://...>",
112            base
113        );
114    }
115    if !is_https_url(&base) {
116        bail!(
117            "Only HTTPS URLs are supported by the MCP add_reference tool: {}\n\n\
118             Please provide an HTTPS URL, e.g.:\n  https://github.com/org/repo(.git)",
119            base
120        );
121    }
122
123    // Parse as RepoIdentity to validate structure
124    let id = RepoIdentity::parse(&base).map_err(|e| {
125        anyhow::anyhow!(
126            "Invalid repository URL (expected host/org/repo).\nDetails: {}",
127            e
128        )
129    })?;
130
131    // For non-GitHub hosts, require .git suffix
132    if id.host != "github.com" && !base.ends_with(".git") {
133        bail!(
134            "For non-GitHub hosts, please provide an HTTPS clone URL ending with .git:\n  {}",
135            base
136        );
137    }
138
139    Ok(())
140}
141
142#[cfg(test)]
143mod tests {
144    use super::*;
145
146    #[test]
147    fn test_sanitize_mount_name() {
148        assert_eq!(sanitize_mount_name("valid-name_123"), "valid-name_123");
149        assert_eq!(sanitize_mount_name("bad name!@#"), "bad_name___");
150        assert_eq!(sanitize_mount_name("CamelCase"), "CamelCase");
151    }
152}
153
154#[cfg(test)]
155mod ref_validation_tests {
156    use super::*;
157
158    #[test]
159    fn test_is_git_url() {
160        assert!(is_git_url("git@github.com:org/repo.git"));
161        assert!(is_git_url("https://github.com/org/repo"));
162        assert!(is_git_url("ssh://user@host:22/org/repo"));
163        assert!(is_git_url("http://gitlab.com/org/repo"));
164        assert!(!is_git_url("org/repo"));
165        assert!(!is_git_url("/local/path"));
166    }
167
168    #[test]
169    fn test_validate_reference_url_accepts_valid() {
170        assert!(validate_reference_url("git@github.com:org/repo.git").is_ok());
171        assert!(validate_reference_url("https://github.com/org/repo").is_ok());
172    }
173
174    #[test]
175    fn test_validate_reference_url_rejects_subpath() {
176        assert!(validate_reference_url("git@github.com:org/repo.git:docs").is_err());
177    }
178
179    #[test]
180    fn test_canonical_reference_key_normalizes() {
181        let a = canonical_reference_key("git@github.com:User/Repo.git").unwrap();
182        let b = canonical_reference_key("https://github.com/user/repo").unwrap();
183        assert_eq!(a, b);
184        assert_eq!(a, ("github.com".into(), "user".into(), "repo".into()));
185    }
186}
187
188#[cfg(test)]
189mod mcp_https_validation_tests {
190    use super::*;
191
192    #[test]
193    fn test_https_only_accepts_github_web_and_clone() {
194        assert!(validate_reference_url_https_only("https://github.com/org/repo").is_ok());
195        assert!(validate_reference_url_https_only("https://github.com/org/repo.git").is_ok());
196    }
197
198    #[test]
199    fn test_https_only_accepts_generic_dot_git() {
200        assert!(validate_reference_url_https_only("https://gitlab.com/group/proj.git").is_ok());
201    }
202
203    #[test]
204    fn test_https_only_rejects_ssh_and_http_and_subpath() {
205        assert!(validate_reference_url_https_only("git@github.com:org/repo.git").is_err());
206        assert!(validate_reference_url_https_only("ssh://host/org/repo.git").is_err());
207        assert!(validate_reference_url_https_only("http://github.com/org/repo.git").is_err());
208        assert!(validate_reference_url_https_only("https://github.com/org/repo.git:docs").is_err());
209    }
210
211    #[test]
212    fn test_is_ssh_url_helper() {
213        assert!(is_ssh_url("git@github.com:org/repo.git"));
214        assert!(is_ssh_url("ssh://user@host/repo.git"));
215        assert!(!is_ssh_url("https://github.com/org/repo"));
216        assert!(!is_ssh_url("http://github.com/org/repo"));
217    }
218
219    #[test]
220    fn test_is_https_url_helper() {
221        assert!(is_https_url("https://github.com/org/repo"));
222        assert!(is_https_url("HTTPS://github.com/org/repo")); // case-insensitive
223        assert!(!is_https_url("http://github.com/org/repo"));
224        assert!(!is_https_url("git@github.com:org/repo"));
225    }
226
227    #[test]
228    fn test_https_only_rejects_non_github_without_dot_git() {
229        // Non-GitHub without .git suffix should be rejected
230        assert!(validate_reference_url_https_only("https://gitlab.com/group/proj").is_err());
231    }
232}