thoughts_tool/config/
validation.rs1use crate::repo_identity::{RepoIdentity, parse_url_and_subpath};
2use anyhow::{Result, bail};
3
4pub 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
14pub 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
23pub 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
36pub 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 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
70pub 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
77pub fn is_ssh_url(s: &str) -> bool {
81 let s = s.trim();
82 s.starts_with("git@") || s.starts_with("ssh://")
83}
84
85pub fn is_https_url(s: &str) -> bool {
87 s.trim_start().to_lowercase().starts_with("https://")
88}
89
90pub fn validate_reference_url_https_only(url: &str) -> Result<()> {
96 let url = url.trim();
97
98 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 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 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")); 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 assert!(validate_reference_url_https_only("https://gitlab.com/group/proj").is_err());
231 }
232}