Skip to main content

spec_driven_docs/gates/
no_personal_path.rs

1//! Gate: a document carries no path into a person's home directory.
2//!
3//! An absolute home path is the author's machine leaking into a file every
4//! reader receives: it resolves for one person and misleads everyone else.
5//! The remedy is always available — write `~/`, `$HOME/`, or a bracketed
6//! placeholder — so this reports the shape rather than guessing at intent.
7//!
8//! Two exemptions, both by purpose rather than by path. A file whose whole
9//! job is one person's environment — `.env`, `.envrc.local`, and the
10//! `.example` copies that show where those values go — is where a home path
11//! belongs, so it is skipped by name. And a file git ignores never reaches
12//! this gate at all, because pre-commit passes only what the repository
13//! tracks.
14//!
15//! Code is not stripped before matching. A fenced example command carrying
16//! a real home directory is exactly the leak this reports, so the rule's own
17//! documents spell the forbidden shape with a placeholder segment instead.
18
19use crate::domain::finding::Finding;
20use crate::domain::rule_id::RuleId;
21use crate::gates::{GateCtx, GateResult, Violation, read_text};
22
23/// The rules this gate can cite.
24pub const CITES: &[RuleId] = &[RuleId::DocumentCarriesNoPersonalPath];
25
26/// The home-directory prefixes a path can start with, POSIX and Windows.
27const ROOTS: &[&str] = &["/home/", "/Users/", "\\Users\\", "/Users\\", "\\Users/"];
28
29/// Segments that stand for a person rather than naming one.
30///
31/// A document teaching the shape needs to write it, and these are the words
32/// it writes. Anything opening with a placeholder marker — `<`, `$`, `{`,
33/// `%` — is a stand-in too, and is judged by shape rather than by list.
34const PLACEHOLDERS: &[&str] = &[
35    "user",
36    "username",
37    "you",
38    "youruser",
39    "your-user",
40    "me",
41    "...",
42];
43
44/// Suffixes an environment file wears when it ships as a fill-in copy.
45const SAMPLE_SUFFIXES: &[&str] = &[".example", ".sample", ".template", ".dist"];
46
47/// Whether this file's purpose is one person's environment.
48fn 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
69/// Whether the segment following a home root stands in for a person.
70fn 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
83/// The user segment that follows a home root at `start`.
84fn 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
94/// Whether the line names a home directory belonging to a particular person.
95///
96/// One line is one finding however many paths it carries: the remedy is the
97/// same for all of them, and a repeated report reads as repeated damage.
98fn 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
125/// Judge every file pre-commit passed.
126///
127/// # Errors
128///
129/// [`crate::gates::GateError::Io`] when a file cannot be read.
130pub 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    /// A POSIX home directory belonging to someone, assembled rather than
162    /// written. This gate strips no code before it matches, so a literal
163    /// here would be a real finding wherever this file reaches the gate.
164    fn home(user: &str) -> String {
165        format!("/home/{user}")
166    }
167
168    /// The macOS spelling of the same.
169    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}