Skip to main content

semora_cli/
project_link.rs

1//! `.semora`: marks a folder as a Semora project, and where a push came from.
2//!
3//! The file is TOML meant to be committed:
4//!
5//! ```toml
6//! server = "https://semora.example.com"
7//! project = "semora"
8//! ```
9//!
10//! `server` is only compared with the configured server, never used: a cloned
11//! repository must not be able to send your API key to another host.
12
13use std::path::{Component, Path, PathBuf};
14
15use anyhow::{Context, bail};
16use serde::Deserialize;
17
18use crate::client::Client;
19use crate::config::{origin, parse_server};
20
21pub const FILE: &str = ".semora";
22
23/// A path for messages: without the `\\?\` prefix that `canonicalize` adds on Windows.
24pub fn shown(path: &Path) -> String {
25    let s = path.display().to_string();
26    match s.strip_prefix(r"\\?\") {
27        Some(rest) => match rest.strip_prefix(r"UNC\") {
28            Some(share) => format!(r"\\{share}"),
29            None => rest.to_string(),
30        },
31        None => s,
32    }
33}
34
35/// A `.semora` names another server than the one this command uses. Commands add their own
36/// way out (push: `--id` or `--new`).
37#[derive(Debug)]
38pub struct OtherServer(pub String);
39
40impl std::fmt::Display for OtherServer {
41    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
42        f.write_str(&self.0)
43    }
44}
45
46impl std::error::Error for OtherServer {}
47
48#[derive(Debug, Clone, PartialEq, Eq)]
49pub struct Link {
50    /// The folder that holds `.semora` (canonical).
51    pub root: PathBuf,
52    /// The server's origin, normalized.
53    pub server: String,
54    pub slug: String,
55}
56
57#[derive(Deserialize)]
58struct Raw {
59    server: Option<String>,
60    project: Option<String>,
61}
62
63/// `[a-z0-9-]`, 1 to 40 characters, not starting with a dash.
64pub fn valid_slug(s: &str) -> bool {
65    (1..=40).contains(&s.len())
66        && s.bytes()
67            .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
68        && !s.starts_with('-')
69}
70
71/// A project id from a directory name: lowercase it, turn every run of other characters into
72/// one `-`, trim `-` from both ends, cut to 40 characters, trim again.
73pub fn slug_from(dir_name: &str) -> Option<String> {
74    let mut out = String::new();
75    for c in dir_name.to_lowercase().chars() {
76        if c.is_ascii_lowercase() || c.is_ascii_digit() {
77            out.push(c);
78        } else if !out.ends_with('-') {
79            out.push('-');
80        }
81    }
82    let cut: String = out.trim_matches('-').chars().take(40).collect();
83    let slug = cut.trim_matches('-').to_string();
84    valid_slug(&slug).then_some(slug)
85}
86
87/// The `.semora` in `dir` itself, if there is one.
88pub fn read_at(dir: &Path) -> anyhow::Result<Option<Link>> {
89    let file = dir.join(FILE);
90    if !file.is_file() {
91        return Ok(None);
92    }
93    let text =
94        std::fs::read_to_string(&file).with_context(|| format!("reading {}", shown(&file)))?;
95    let raw: Raw = toml::from_str(&text).with_context(|| format!("parsing {}", shown(&file)))?;
96    let server = raw
97        .server
98        .filter(|s| !s.trim().is_empty())
99        .with_context(|| format!("{} needs a `server` line", shown(&file)))?;
100    let slug = raw
101        .project
102        .filter(|s| !s.trim().is_empty())
103        .with_context(|| format!("{} needs a `project` line", shown(&file)))?;
104    if !valid_slug(&slug) {
105        bail!(
106            "{}: `{slug}` is not a valid project id (lowercase letters, digits and dashes, up to 40, starting with a letter or digit)",
107            shown(&file)
108        );
109    }
110    let server =
111        parse_server(&server).with_context(|| format!("{}: bad `server`", shown(&file)))?;
112    Ok(Some(Link {
113        root: dir.to_path_buf(),
114        server: origin(&server),
115        slug,
116    }))
117}
118
119/// The folder to start looking from: the path's folder, or the path itself for a folder.
120fn start_dir(full: &Path) -> Option<&Path> {
121    if full.is_dir() {
122        Some(full)
123    } else {
124        full.parent()
125    }
126}
127
128/// Looks for `.semora` in the path's folder and each parent. The nearest file wins. The
129/// current directory plays no part. `Err` means a file was found but is unusable.
130pub fn find(path: &Path) -> anyhow::Result<Option<Link>> {
131    let full =
132        std::fs::canonicalize(path).with_context(|| format!("cannot read {}", shown(path)))?;
133    let mut dir = start_dir(&full);
134    while let Some(d) = dir {
135        if let Some(link) = read_at(d)? {
136            return Ok(Some(link));
137        }
138        dir = d.parent();
139    }
140    Ok(None)
141}
142
143/// Writes `dir/.semora`. Returns its path.
144pub fn write(dir: &Path, server: &str, slug: &str) -> anyhow::Result<PathBuf> {
145    let file = dir.join(FILE);
146    let text = format!(
147        "# Semora project. Commit this file so every checkout publishes to the same place.\nserver = \"{server}\"\nproject = \"{slug}\"\n"
148    );
149    std::fs::write(&file, text).with_context(|| format!("writing {}", shown(&file)))?;
150    Ok(file)
151}
152
153impl Link {
154    /// The server named in `.semora` must be the one this command uses.
155    pub fn check_origin(&self, have: &str) -> anyhow::Result<()> {
156        if self.server == have {
157            return Ok(());
158        }
159        Err(OtherServer(format!(
160            "{} is for {}, but this command uses {have}. To use that server, set SEMORA_URL or pass --server {}.",
161            shown(&self.root.join(FILE)),
162            self.server,
163            self.server
164        ))
165        .into())
166    }
167
168    pub fn check_server(&self, client: &Client) -> anyhow::Result<()> {
169        self.check_origin(&client.origin())
170    }
171
172    /// The pushed path relative to the project root, joined with `/`.
173    pub fn relative_path(&self, target: &Path) -> anyhow::Result<String> {
174        let full = std::fs::canonicalize(target)
175            .with_context(|| format!("cannot read {}", shown(target)))?;
176        let rel = full.strip_prefix(&self.root).with_context(|| {
177            format!(
178                "{} is not inside the project at {}",
179                shown(&full),
180                shown(&self.root)
181            )
182        })?;
183        let mut parts = Vec::new();
184        for c in rel.components() {
185            let s = c.as_os_str().to_str().with_context(|| {
186                format!(
187                    "{} isn't valid Unicode, so it can't be a project path",
188                    shown(&full)
189                )
190            })?;
191            parts.push(s.to_string());
192        }
193        if parts.is_empty() {
194            bail!(
195                "{} is here: push a file or a folder inside this project, not the project root.",
196                shown(&self.root.join(FILE))
197            );
198        }
199        Ok(parts.join("/"))
200    }
201}
202
203/// The branch of the git checkout the path is in, read from `HEAD` (no `git` process).
204/// A worktree's `.git` is a file that names its git folder; a relative name is relative to
205/// the folder that holds that file. The walk starts from `std::path::absolute`, not
206/// `canonicalize`: on Windows a canonical path is a `\\?\` path, and those do not resolve `..`
207/// or `/`, so the relative name is joined and cleaned up lexically instead.
208pub fn git_branch(path: &Path) -> Option<String> {
209    let full = std::path::absolute(path).ok()?;
210    let mut dir = start_dir(&full);
211    while let Some(d) = dir {
212        let dot = d.join(".git");
213        if dot.is_dir() {
214            return branch_in(&dot);
215        }
216        if dot.is_file() {
217            let text = std::fs::read_to_string(&dot).ok()?;
218            let target = text
219                .lines()
220                .find_map(|l| l.trim().strip_prefix("gitdir:"))?
221                .trim();
222            let gitdir = if Path::new(target).is_absolute() {
223                PathBuf::from(target)
224            } else {
225                lexical(&d.join(target))
226            };
227            return branch_in(&gitdir);
228        }
229        dir = d.parent();
230    }
231    None
232}
233
234/// `.` and `..` resolved without touching the file system.
235fn lexical(path: &Path) -> PathBuf {
236    let mut out = PathBuf::new();
237    for c in path.components() {
238        match c {
239            Component::CurDir => {}
240            Component::ParentDir => {
241                if !out.pop() {
242                    out.push("..");
243                }
244            }
245            other => out.push(other.as_os_str()),
246        }
247    }
248    out
249}
250
251fn branch_in(gitdir: &Path) -> Option<String> {
252    let head = std::fs::read_to_string(gitdir.join("HEAD")).ok()?;
253    head.trim()
254        .strip_prefix("ref: refs/heads/")
255        .map(str::to_string)
256        .filter(|b| !b.is_empty())
257}
258
259/// Where a push came from: `machine ยท branch`, or just the machine.
260pub fn origin_for(path: &Path) -> String {
261    let machine = crate::commands::login::hostname();
262    match git_branch(path) {
263        Some(b) => format!("{machine} \u{b7} {b}"),
264        None => machine,
265    }
266}
267
268#[cfg(test)]
269mod tests {
270    use super::*;
271
272    fn dir() -> tempfile::TempDir {
273        tempfile::tempdir().unwrap()
274    }
275
276    fn put(root: &Path, rel: &str, text: &str) -> PathBuf {
277        let p = root.join(rel);
278        std::fs::create_dir_all(p.parent().unwrap()).unwrap();
279        std::fs::write(&p, text).unwrap();
280        p
281    }
282
283    #[test]
284    fn slugs_follow_the_rule() {
285        let (x40, x41) = ("x".repeat(40), "x".repeat(41));
286        for ok in ["semora", "a", "9-a", x40.as_str()] {
287            assert!(valid_slug(ok), "{ok}");
288        }
289        for bad in ["", "Semora", "-x", "a b", x41.as_str()] {
290            assert!(!valid_slug(bad), "{bad}");
291        }
292    }
293
294    #[test]
295    fn a_slug_is_made_from_a_directory_name() {
296        assert_eq!(slug_from("My Docs_v2").as_deref(), Some("my-docs-v2"));
297        assert_eq!(slug_from(".tmpAbC123").as_deref(), Some("tmpabc123"));
298        assert_eq!(slug_from("--a--b--").as_deref(), Some("a-b"));
299        assert_eq!(slug_from("semora").as_deref(), Some("semora"));
300        assert_eq!(slug_from(&"a".repeat(50)).unwrap().len(), 40);
301        let a39 = "a".repeat(39);
302        assert_eq!(
303            slug_from(&format!("{a39}-b")).as_deref(),
304            Some(a39.as_str()),
305            "no dash left at the cut"
306        );
307        assert_eq!(slug_from("___"), None);
308        assert_eq!(slug_from(""), None);
309    }
310
311    #[test]
312    fn dot_semora_is_parsed_and_checked() {
313        let d = dir();
314        put(
315            d.path(),
316            ".semora",
317            "# note\nserver = \"https://Semora.example.com/\"\nproject = \"semora\"\nextra = 1\n",
318        );
319        let link = read_at(d.path()).unwrap().unwrap();
320        assert_eq!(link.slug, "semora");
321        assert_eq!(
322            link.server, "https://semora.example.com",
323            "the origin, whatever the spelling"
324        );
325
326        for (text, why) in [
327            ("project = \"x\"\n", "server"),
328            ("server = \"https://a.example.com\"\n", "project"),
329            (
330                "server = \"https://a.example.com\"\nproject = \"Bad Id\"\n",
331                "valid project id",
332            ),
333            ("server = \n", "parsing"),
334        ] {
335            put(d.path(), ".semora", text);
336            let err = read_at(d.path()).unwrap_err().to_string();
337            assert!(
338                err.contains(why) && err.contains(".semora"),
339                "{text:?} -> {err}"
340            );
341        }
342        std::fs::remove_file(d.path().join(".semora")).unwrap();
343        assert_eq!(read_at(d.path()).unwrap(), None);
344    }
345
346    #[test]
347    fn find_walks_up_from_the_path_and_the_nearest_file_wins() {
348        let d = dir();
349        put(
350            d.path(),
351            ".semora",
352            "server = \"https://a.example.com\"\nproject = \"outer\"\n",
353        );
354        put(
355            d.path(),
356            "inner/.semora",
357            "server = \"https://a.example.com\"\nproject = \"inner\"\n",
358        );
359        let file = put(d.path(), "docs/deep/x.md", "# x");
360        put(d.path(), "inner/y.md", "# y");
361        assert_eq!(find(&file).unwrap().unwrap().slug, "outer");
362        assert_eq!(
363            find(&d.path().join("docs")).unwrap().unwrap().slug,
364            "outer",
365            "a folder"
366        );
367        assert_eq!(
368            find(&d.path().join("inner/y.md")).unwrap().unwrap().slug,
369            "inner"
370        );
371        assert_eq!(
372            find(&d.path().join("inner")).unwrap().unwrap().slug,
373            "inner",
374            "the folder's own file"
375        );
376        let lonely = tempfile::tempdir().unwrap();
377        let f = put(lonely.path(), "z.md", "# z");
378        assert_eq!(find(&f).unwrap(), None);
379    }
380
381    #[test]
382    fn the_key_path_is_relative_with_forward_slashes() {
383        let d = dir();
384        put(
385            d.path(),
386            ".semora",
387            "server = \"https://a.example.com\"\nproject = \"p\"\n",
388        );
389        let file = put(d.path(), "docs/deep/x.md", "# x");
390        let link = find(&file).unwrap().unwrap();
391        assert_eq!(link.relative_path(&file).unwrap(), "docs/deep/x.md");
392        assert_eq!(link.relative_path(&d.path().join("docs")).unwrap(), "docs");
393        let err = link.relative_path(d.path()).unwrap_err().to_string();
394        assert!(err.contains("not the project root"), "{err}");
395    }
396
397    #[test]
398    fn a_link_for_another_server_names_both() {
399        let d = dir();
400        put(
401            d.path(),
402            ".semora",
403            "server = \"https://a.example.com\"\nproject = \"p\"\n",
404        );
405        let link = read_at(d.path()).unwrap().unwrap();
406        assert!(link.check_origin("https://a.example.com").is_ok());
407        let err = link
408            .check_origin("https://b.example.com")
409            .unwrap_err()
410            .to_string();
411        assert!(
412            err.contains("https://a.example.com") && err.contains("https://b.example.com"),
413            "{err}"
414        );
415        assert!(
416            err.contains("--server") && err.contains("SEMORA_URL"),
417            "says how to use the other server: {err}"
418        );
419        assert!(
420            !err.contains("--id") && !err.contains("--new"),
421            "push flags are push's to mention, not every command's: {err}"
422        );
423    }
424
425    #[test]
426    fn write_makes_a_file_that_reads_back() {
427        let d = dir();
428        let path = write(d.path(), "https://a.example.com", "semora").unwrap();
429        let text = std::fs::read_to_string(&path).unwrap();
430        assert!(text.starts_with("# Semora project."), "{text}");
431        let link = read_at(d.path()).unwrap().unwrap();
432        assert_eq!(
433            (link.server.as_str(), link.slug.as_str()),
434            ("https://a.example.com", "semora")
435        );
436    }
437
438    #[test]
439    fn paths_in_messages_lose_the_windows_verbatim_prefix() {
440        assert_eq!(
441            shown(Path::new(r"\\?\C:\Development\x\.semora")),
442            r"C:\Development\x\.semora"
443        );
444        assert_eq!(shown(Path::new(r"\\?\UNC\nas\share\x")), r"\\nas\share\x");
445        assert_eq!(shown(Path::new("/home/a/x")), "/home/a/x");
446    }
447
448    #[test]
449    fn dots_are_resolved_without_the_file_system() {
450        assert_eq!(lexical(Path::new("/a/b/../c/./d")), PathBuf::from("/a/c/d"));
451        assert_eq!(lexical(Path::new("a/../../b")), PathBuf::from("../b"));
452    }
453
454    #[test]
455    fn the_branch_comes_from_head() {
456        let d = dir();
457        put(d.path(), ".git/HEAD", "ref: refs/heads/feat/projects\n");
458        let file = put(d.path(), "docs/x.md", "# x");
459        assert_eq!(git_branch(&file).as_deref(), Some("feat/projects"));
460        put(
461            d.path(),
462            ".git/HEAD",
463            "0123456789abcdef0123456789abcdef01234567\n",
464        );
465        assert_eq!(git_branch(&file), None, "a detached HEAD has no branch");
466    }
467
468    #[test]
469    fn a_worktree_points_at_its_git_folder_absolute_or_relative() {
470        let d = dir();
471        let main = put(
472            d.path(),
473            "main/.git/worktrees/wt/HEAD",
474            "ref: refs/heads/feat/wt\n",
475        );
476        let abs = main.parent().unwrap().to_string_lossy().replace('\\', "/");
477        put(d.path(), "abs/.git", &format!("gitdir: {abs}\n"));
478        let file = put(d.path(), "abs/x.md", "# x");
479        assert_eq!(
480            git_branch(&file).as_deref(),
481            Some("feat/wt"),
482            "absolute gitdir"
483        );
484        // Relative to the folder that holds the `.git` file, not to the current directory.
485        put(d.path(), "rel/.git", "gitdir: ../main/.git/worktrees/wt\n");
486        let file = put(d.path(), "rel/x.md", "# x");
487        assert_eq!(
488            git_branch(&file).as_deref(),
489            Some("feat/wt"),
490            "relative gitdir"
491        );
492    }
493
494    #[test]
495    fn a_verbatim_path_still_reads_a_relative_gitdir() {
496        // `watch` passes a canonical path; on Windows that is a `\\?\` path, where `/` is not a
497        // separator, so a relative `gitdir` must not be joined onto it as it is.
498        let d = dir();
499        put(
500            d.path(),
501            "main/.git/worktrees/wt/HEAD",
502            "ref: refs/heads/feat/wt\n",
503        );
504        put(d.path(), "rel/.git", "gitdir: ../main/.git/worktrees/wt\n");
505        let file = put(d.path(), "rel/x.md", "# x");
506        let canonical = std::fs::canonicalize(&file).unwrap();
507        if cfg!(windows) {
508            assert!(
509                canonical.to_string_lossy().starts_with(r"\\?\"),
510                "{canonical:?}"
511            );
512        }
513        assert_eq!(git_branch(&canonical).as_deref(), Some("feat/wt"));
514    }
515
516    #[test]
517    fn origin_is_the_machine_and_the_branch_when_there_is_one() {
518        let d = dir();
519        let file = put(d.path(), "x.md", "# x");
520        let machine = origin_for(&file);
521        assert!(!machine.contains('\u{b7}'), "no branch: {machine}");
522        put(d.path(), ".git/HEAD", "ref: refs/heads/main\n");
523        let with = origin_for(&file);
524        assert_eq!(with, format!("{machine} \u{b7} main"));
525    }
526}