spec_driven_docs/gates/
no_personal_path.rs1use crate::domain::finding::Finding;
20use crate::domain::rule_id::RuleId;
21use crate::gates::{GateCtx, GateResult, Violation, read_text};
22
23pub const CITES: &[RuleId] = &[RuleId::DocumentCarriesNoPersonalPath];
25
26const ROOTS: &[&str] = &["/home/", "/Users/", "\\Users\\", "/Users\\", "\\Users/"];
28
29const PLACEHOLDERS: &[&str] = &[
35 "user",
36 "username",
37 "you",
38 "youruser",
39 "your-user",
40 "me",
41 "...",
42];
43
44const SAMPLE_SUFFIXES: &[&str] = &[".example", ".sample", ".template", ".dist"];
46
47fn is_environment_file(path: &str) -> bool {
49 let mut name = path.rsplit(['/', '\\']).next().unwrap_or(path);
50 loop {
51 let trimmed = SAMPLE_SUFFIXES
52 .iter()
53 .find_map(|suffix| name.strip_suffix(suffix));
54 match trimmed {
55 Some(rest) => name = rest,
56 None => break,
57 }
58 }
59 name == ".env"
60 || name.starts_with(".env.")
61 || name == ".envrc"
62 || name.starts_with(".envrc.")
63 || name
64 .split('.')
65 .skip(1)
66 .any(|part| part.eq_ignore_ascii_case("local"))
67}
68
69fn is_placeholder(segment: &str) -> bool {
71 if segment.is_empty() {
72 return true;
73 }
74 if segment.starts_with(['<', '$', '{', '%']) {
75 return true;
76 }
77 let bare = segment.trim_matches(['<', '>', '{', '}', '%', '$']);
78 PLACEHOLDERS
79 .iter()
80 .any(|placeholder| bare.eq_ignore_ascii_case(placeholder))
81}
82
83fn segment_after(line: &str, start: usize) -> &str {
85 let rest = &line[start..];
86 let end = rest
87 .find([
88 '/', '\\', ' ', '\t', '"', '\'', '`', ')', ']', ',', ';', ':',
89 ])
90 .unwrap_or(rest.len());
91 &rest[..end]
92}
93
94fn carries_personal_path(line: &str) -> bool {
99 for root in ROOTS {
100 let mut from = 0;
101 while let Some(offset) = line[from..].find(root) {
102 let start = from + offset + root.len();
103 if !is_placeholder(segment_after(line, start)) {
104 return true;
105 }
106 from = start;
107 }
108 }
109 false
110}
111
112fn judge(file: &str, text: &str, violations: &mut Vec<Violation>) {
113 for (index, raw) in text.lines().enumerate() {
114 if carries_personal_path(raw) {
115 violations.push(Violation::Finding(Finding::on_line(
116 RuleId::DocumentCarriesNoPersonalPath,
117 file,
118 index + 1,
119 raw.to_string(),
120 )));
121 }
122 }
123}
124
125pub fn run(ctx: &GateCtx, files: &[String]) -> GateResult {
131 let mut violations = Vec::new();
132 for file in files {
133 if is_environment_file(file) {
134 continue;
135 }
136 let text = read_text(ctx, file)?;
137 judge(file, &text, &mut violations);
138 }
139 Ok(violations)
140}
141
142#[cfg(test)]
143mod tests {
144 use super::*;
145
146 fn run_on_named(name: &str, text: &str) -> Vec<String> {
147 let dir = tempfile::tempdir().unwrap();
148 std::fs::write(dir.path().join(name), text).unwrap();
149 let ctx = GateCtx::new(dir.path().to_str().unwrap());
150 run(&ctx, &[name.to_string()])
151 .unwrap()
152 .iter()
153 .map(ToString::to_string)
154 .collect()
155 }
156
157 fn run_on(text: &str) -> Vec<String> {
158 run_on_named("doc.md", text)
159 }
160
161 fn home(user: &str) -> String {
165 format!("/home/{user}")
166 }
167
168 fn mac_home(user: &str) -> String {
170 format!("/Users/{user}")
171 }
172
173 #[test]
174 fn accepts_a_home_relative_path() {
175 assert!(run_on("Install into `~/.local/bin` or `$HOME/bin`.\n").is_empty());
176 }
177
178 #[test]
179 fn rejects_an_absolute_home_path_naming_its_owner() {
180 let path = home("ada");
181 let out = run_on(&format!("Run it from {path}/projects/widget.\n"));
182 assert_eq!(
183 out,
184 vec![format!(
185 "FAIL docs-foundations:a-document-carries-no-personal-path doc.md:1: Run it from {path}/projects/widget."
186 )]
187 );
188 }
189
190 #[test]
191 fn rejects_a_macos_home_path() {
192 let text = format!("See {}/Library/logs.\n", mac_home("ada"));
193 assert_eq!(run_on(&text).len(), 1);
194 }
195
196 #[test]
197 fn a_placeholder_segment_is_the_documented_shape() {
198 assert!(run_on("Write it as /home/<user>/notes or /Users/you/notes.\n").is_empty());
199 }
200
201 #[test]
202 fn a_shell_variable_segment_is_a_placeholder() {
203 assert!(run_on("Expands to /home/$USER/.config.\n").is_empty());
204 }
205
206 #[test]
207 fn a_fenced_example_is_judged_like_prose() {
208 let text = format!("Run:\n\n```bash\ncd {}/src\n```\n", home("ada"));
209 let out = run_on(&text);
210 assert_eq!(out.len(), 1);
211 assert!(out[0].contains("doc.md:4"));
212 }
213
214 #[test]
215 fn one_line_reports_once_however_many_paths_it_carries() {
216 let text = format!("{} and {} both.\n", home("ada"), home("grace"));
217 assert_eq!(run_on(&text).len(), 1);
218 }
219
220 #[test]
221 fn an_environment_file_may_carry_a_real_path() {
222 let key = format!("KEY={}/key.pem\n", home("ada"));
223 for name in [".envrc.local", ".env.example", ".env"] {
224 assert!(
225 run_on_named(name, &key).is_empty(),
226 "{name} is an environment file and may carry a real path"
227 );
228 }
229 }
230
231 #[test]
232 fn an_ordinary_document_is_not_exempted_by_a_sample_suffix() {
233 let text = format!("{}/x\n", home("ada"));
234 assert_eq!(run_on_named("guide.md.example", &text).len(), 1);
235 }
236
237 #[test]
238 fn a_document_named_for_a_locale_is_not_an_environment_file() {
239 let text = format!("{}/x\n", home("ada"));
240 assert_eq!(run_on_named("local.md", &text).len(), 1);
241 }
242}