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 = "3f9c0e7a5b1d4e2f8a6c9b0d1e2f3a4b"
7//! project = "semora"
8//! ```
9//!
10//! `server` is the id the server gave itself, so the file does not say where the server is.
11//! It is only compared with the id of the configured server, never used to reach one: a cloned
12//! repository must not be able to send your API key to another host. A file from before ids
13//! holds the server's URL; one for the configured server is moved to the id on first use.
14
15use std::path::{Component, Path, PathBuf};
16
17use anyhow::{Context, bail};
18use serde::Deserialize;
19
20use crate::client::Client;
21use crate::config::{origin, parse_server};
22use crate::local_links::{self, LocalLink};
23use crate::{checkout, exit, output, server_id};
24
25pub const FILE: &str = ".semora";
26
27/// A path for messages: without the `\\?\` prefix that `canonicalize` adds on Windows.
28pub fn shown(path: &Path) -> String {
29    let s = path.display().to_string();
30    match s.strip_prefix(r"\\?\") {
31        Some(rest) => match rest.strip_prefix(r"UNC\") {
32            Some(share) => format!(r"\\{share}"),
33            None => rest.to_string(),
34        },
35        None => s,
36    }
37}
38
39/// A `.semora` names another server than the one this command uses. Commands add their own
40/// way out (push: `--id` or `--new`).
41#[derive(Debug)]
42pub struct OtherServer(pub String);
43
44impl std::fmt::Display for OtherServer {
45    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
46        f.write_str(&self.0)
47    }
48}
49
50impl std::error::Error for OtherServer {}
51
52/// What a `.semora` names its server by.
53#[derive(Debug, Clone, PartialEq, Eq)]
54pub enum ServerRef {
55    /// The server's id, trimmed and lowercase.
56    Id(String),
57    /// A URL from before ids, as an origin.
58    Url(String),
59}
60
61/// A `.semora` as it is written, before the CLI has asked its server whether the file is for it.
62#[derive(Debug, Clone, PartialEq, Eq)]
63pub struct FileLink {
64    /// The folder that holds `.semora` (canonical).
65    pub root: PathBuf,
66    pub server: ServerRef,
67    pub slug: String,
68}
69
70/// A `.semora` that is for the server this command uses.
71#[derive(Debug, Clone, PartialEq, Eq)]
72pub struct Link {
73    /// The folder that holds `.semora` (canonical).
74    pub root: PathBuf,
75    /// The origin of the server this command uses.
76    pub server: String,
77    /// The project id the commands use.
78    pub slug: String,
79    /// The project id `.semora` names.
80    pub file_slug: String,
81    /// Set when the file is used for this server through a record on this machine
82    /// (`semora project link`), not because it names this server. A new project id then goes in
83    /// the record, never in the shared file.
84    pub local: Option<Mapped>,
85}
86
87/// The record a link is used through, and the config folder that holds it.
88#[derive(Debug, Clone, PartialEq, Eq)]
89pub struct Mapped {
90    pub config_dir: PathBuf,
91    pub record: LocalLink,
92}
93
94impl Link {
95    pub fn mapped(&self) -> bool {
96        self.local.is_some()
97    }
98}
99
100/// How a `.semora` relates to the server this command uses.
101#[derive(Debug, Clone, PartialEq, Eq)]
102pub enum Fit {
103    /// It names this server's id.
104    Ours,
105    /// It names this server by URL, from before ids.
106    LegacyOurs,
107    /// It names another server by URL, this origin.
108    LegacyOther(String),
109    /// It names another server's id.
110    Other,
111    /// It names another server, and a record on this machine uses it with this one.
112    Mapped(LocalLink),
113}
114
115/// The `server` value of a file as local records key it.
116pub fn server_value(server: &ServerRef) -> String {
117    match server {
118        ServerRef::Id(id) | ServerRef::Url(id) => id.clone(),
119    }
120}
121
122#[derive(Deserialize)]
123struct Raw {
124    server: Option<String>,
125    project: Option<String>,
126}
127
128/// `[a-z0-9-]`, 1 to 40 characters, not starting with a dash.
129pub fn valid_slug(s: &str) -> bool {
130    (1..=40).contains(&s.len())
131        && s.bytes()
132            .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
133        && !s.starts_with('-')
134}
135
136/// A project id from a directory name: lowercase it, turn every run of other characters into
137/// one `-`, trim `-` from both ends, cut to 40 characters, trim again.
138pub fn slug_from(dir_name: &str) -> Option<String> {
139    let mut out = String::new();
140    for c in dir_name.to_lowercase().chars() {
141        if c.is_ascii_lowercase() || c.is_ascii_digit() {
142            out.push(c);
143        } else if !out.ends_with('-') {
144            out.push('-');
145        }
146    }
147    let cut: String = out.trim_matches('-').chars().take(40).collect();
148    let slug = cut.trim_matches('-').to_string();
149    valid_slug(&slug).then_some(slug)
150}
151
152/// The `.semora` in `dir` itself, if there is one.
153pub fn read_at(dir: &Path) -> anyhow::Result<Option<FileLink>> {
154    let file = dir.join(FILE);
155    if !file.is_file() {
156        return Ok(None);
157    }
158    let text =
159        std::fs::read_to_string(&file).with_context(|| format!("reading {}", shown(&file)))?;
160    let raw: Raw = toml::from_str(&text).with_context(|| format!("parsing {}", shown(&file)))?;
161    let server = raw
162        .server
163        .filter(|s| !s.trim().is_empty())
164        .with_context(|| format!("{} needs a `server` line", shown(&file)))?;
165    let slug = raw
166        .project
167        .filter(|s| !s.trim().is_empty())
168        .with_context(|| format!("{} needs a `project` line", shown(&file)))?;
169    if !valid_slug(&slug) {
170        bail!(
171            "{}: `{slug}` is not a valid project id (lowercase letters, digits and dashes, up to 40, starting with a letter or digit)",
172            shown(&file)
173        );
174    }
175    let server = if server_id::is_id(&server) {
176        ServerRef::Id(server_id::normal(&server))
177    } else {
178        let url =
179            parse_server(&server).with_context(|| format!("{}: bad `server`", shown(&file)))?;
180        ServerRef::Url(origin(&url))
181    };
182    Ok(Some(FileLink {
183        root: dir.to_path_buf(),
184        server,
185        slug,
186    }))
187}
188
189/// The folder to start looking from: the path's folder, or the path itself for a folder.
190fn start_dir(full: &Path) -> Option<&Path> {
191    if full.is_dir() {
192        Some(full)
193    } else {
194        full.parent()
195    }
196}
197
198/// Looks for `.semora` in the path's folder and each parent. The nearest file wins. The
199/// current directory plays no part. `Err` means a file was found but is unusable.
200pub fn find(path: &Path) -> anyhow::Result<Option<FileLink>> {
201    let full =
202        std::fs::canonicalize(path).with_context(|| format!("cannot read {}", shown(path)))?;
203    let mut dir = start_dir(&full);
204    while let Some(d) = dir {
205        if let Some(link) = read_at(d)? {
206            return Ok(Some(link));
207        }
208        dir = d.parent();
209    }
210    Ok(None)
211}
212
213/// Writes `dir/.semora` naming the server by its id. Returns its path.
214pub fn write(dir: &Path, server_id: &str, slug: &str) -> anyhow::Result<PathBuf> {
215    let file = dir.join(FILE);
216    let text = format!(
217        "# Semora project. Commit this file so every checkout publishes to the same place.\nserver = \"{server_id}\"\nproject = \"{slug}\"\n"
218    );
219    std::fs::write(&file, text).with_context(|| format!("writing {}", shown(&file)))?;
220    Ok(file)
221}
222
223/// Sets the `key` line (`project` or `server`) of `dir/.semora` to `value`, keeping every other
224/// line as it is.
225pub fn set_line(dir: &Path, key: &str, value: &str) -> anyhow::Result<()> {
226    let file = dir.join(FILE);
227    let text =
228        std::fs::read_to_string(&file).with_context(|| format!("reading {}", shown(&file)))?;
229    let eol = if text.contains("\r\n") { "\r\n" } else { "\n" };
230    let mut found = false;
231    let mut lines: Vec<String> = Vec::new();
232    for line in text.lines() {
233        let is_key = line
234            .trim_start()
235            .strip_prefix(key)
236            .is_some_and(|rest| rest.trim_start().starts_with('='));
237        if is_key && !found {
238            found = true;
239            lines.push(format!("{key} = \"{value}\""));
240        } else {
241            lines.push(line.to_string());
242        }
243    }
244    if !found {
245        bail!("{} has no `{key}` line to update", shown(&file));
246    }
247    let new_text = lines.join(eol) + eol;
248    std::fs::write(&file, &new_text).with_context(|| format!("writing {}", shown(&file)))?;
249    // The line must have said what we meant; if not, put the file back.
250    let meant = |l: &FileLink| match key {
251        "project" => l.slug == value,
252        _ => l.server == ServerRef::Id(value.to_string()),
253    };
254    match read_at(dir) {
255        Ok(Some(l)) if meant(&l) => Ok(()),
256        _ => {
257            std::fs::write(&file, &text).ok();
258            bail!("could not update the `{key}` line in {}", shown(&file))
259        }
260    }
261}
262
263/// The project has the id `slug` on the server now. Rewrites `.semora` (or, for a link used
264/// through a record on this machine, the record) and the checkout record to it and says so. Any
265/// other answer leaves the link as it is.
266pub fn adopt(link: Link, slug: &str) -> Link {
267    if slug == link.slug || !valid_slug(slug) {
268        return link;
269    }
270    if let Some(m) = &link.local {
271        let record = LocalLink {
272            project: slug.to_string(),
273            ..m.record.clone()
274        };
275        if let Err(e) = local_links::save(&m.config_dir, record.clone()) {
276            output::warn(format!(
277                "project `{}` is now `{slug}` on {}, but this machine's link could not be updated: {e:#}. Run `semora project link --project {slug}`.",
278                link.slug, link.server
279            ));
280            return link;
281        }
282        let moved = Link {
283            slug: slug.to_string(),
284            local: Some(Mapped {
285                config_dir: m.config_dir.clone(),
286                record,
287            }),
288            ..link.clone()
289        };
290        if let Err(e) = checkout::move_to(&link, &moved) {
291            output::warn(format!(
292                "could not carry this folder's record over to the new id: {e:#}. Run `semora pull` to start it again."
293            ));
294        }
295        output::warn(format!(
296            "project '{}' is now '{slug}' on {}. Updated this machine's link.",
297            link.slug, link.server
298        ));
299        return moved;
300    }
301    if let Err(e) = set_line(&link.root, "project", slug) {
302        output::warn(format!(
303            "project `{}` is now `{slug}` on {}, but {e:#}. Change the `project` line yourself.",
304            link.slug, link.server
305        ));
306        return link;
307    }
308    let moved = Link {
309        slug: slug.to_string(),
310        file_slug: slug.to_string(),
311        ..link.clone()
312    };
313    if let Err(e) = checkout::move_to(&link, &moved) {
314        output::warn(format!(
315            "could not carry this folder's record over to the new id: {e:#}. Run `semora pull` to start it again."
316        ));
317    }
318    output::warn(format!(
319        "project `{}` is now `{slug}` on {}. Updated {}; commit it so other checkouts follow.",
320        link.slug,
321        link.server,
322        shown(&link.root.join(FILE))
323    ));
324    moved
325}
326
327/// The project as the server knows it now. An owner can give a project a new id; while the old
328/// one is an alias the server answers it with the new one. When the old id is gone, a document
329/// this folder pulled or pushed still says which project it is in. Errors other than a missing
330/// project are left for the command itself to report.
331pub async fn follow(client: &Client, link: Link) -> anyhow::Result<Link> {
332    let link = match client.project(&link.slug).await {
333        Ok(info) => adopt(link, &info.slug),
334        Err(e) if exit::is_not_found(&e) => recover(client, link).await,
335        Err(_) => link,
336    };
337    keep_record(client, &link).await;
338    Ok(link)
339}
340
341/// Another checkout followed the project to a new id and the new `.semora` came in with git,
342/// so this folder's record is still under the old id. When a document in it is in this
343/// project now, the record is kept under the new id; without it, pushes could no longer tell
344/// a stale copy, and pull could no longer tell local changes from the server's.
345async fn keep_record(client: &Client, link: &Link) {
346    for id in checkout::ids_under_other_id(link)
347        .into_iter()
348        .take(RECOVER_TRIES)
349    {
350        let Ok(d) = client.doc(&id).await else {
351            continue;
352        };
353        if d.summary.project.as_deref() == Some(link.slug.as_str())
354            && let Err(e) = checkout::relabel(link)
355        {
356            output::warn(format!(
357                "could not carry this folder's record over to project `{}`: {e:#}",
358                link.slug
359            ));
360        }
361        // The first document still there decides: in another project, the record is not ours.
362        return;
363    }
364}
365
366/// How many recorded documents to ask about before giving up.
367const RECOVER_TRIES: usize = 3;
368
369/// The old id is unknown to the server. Asks for documents this folder recorded and takes the
370/// project the first one that is still there is in.
371async fn recover(client: &Client, link: Link) -> Link {
372    let Ok(records) = checkout::load(&link) else {
373        return link;
374    };
375    let mut ids: Vec<String> = records.into_values().map(|r| r.id).collect();
376    ids.dedup();
377    for id in ids.into_iter().take(RECOVER_TRIES) {
378        if let Ok(d) = client.doc(&id).await
379            && let Some(project) = d.summary.project
380        {
381            return adopt(link, &project);
382        }
383    }
384    link
385}
386
387/// How `file` relates to the server `client` uses. An id that differs from the cached one is
388/// checked once more with the server, in case the address now leads to another server. A file
389/// for another server may still be used here through a record on this machine.
390pub async fn fit(client: &Client, config_dir: &Path, file: &FileLink) -> anyhow::Result<Fit> {
391    let ours = match &file.server {
392        ServerRef::Id(id) => {
393            *id == server_id::of(client, config_dir, false).await?
394                || *id == server_id::of(client, config_dir, true).await?
395        }
396        ServerRef::Url(u) if *u == client.origin() => return Ok(Fit::LegacyOurs),
397        ServerRef::Url(_) => false,
398    };
399    if ours {
400        return Ok(Fit::Ours);
401    }
402    if let Some(record) = local_links::find(config_dir, &server_value(&file.server), &file.slug)? {
403        let id = server_id::of(client, config_dir, false).await?;
404        if record.server_id == id
405            || record.server_id == server_id::of(client, config_dir, true).await?
406        {
407            return Ok(Fit::Mapped(record));
408        }
409    }
410    Ok(match &file.server {
411        ServerRef::Url(u) => Fit::LegacyOther(u.clone()),
412        ServerRef::Id(_) => Fit::Other,
413    })
414}
415
416/// `file` as a link for the server `client` uses, or the error that says why it is not and what
417/// to do. Nothing is sent for the project before this answers.
418pub async fn resolve(client: &Client, config_dir: &Path, file: FileLink) -> anyhow::Result<Link> {
419    let path = shown(&file.root.join(FILE));
420    let have = client.origin();
421    let mut local = None;
422    let mut slug = file.slug.clone();
423    match fit(client, config_dir, &file).await? {
424        Fit::Ours => drop_shadowed(client, config_dir, &file, &path).await,
425        Fit::LegacyOurs => {
426            // The id goes into a committed file, so it is asked for, not taken from the cache.
427            let id = server_id::of(client, config_dir, true).await?;
428            match set_line(&file.root, "server", &id) {
429                Ok(()) => output::warn(format!(
430                    "{path} now names its server by id instead of address. Commit it. Older commits still hold the address."
431                )),
432                Err(e) => output::warn(format!(
433                    "could not update {path}: {e:#}. Change the `server` line to \"{id}\" yourself."
434                )),
435            }
436        }
437        Fit::Mapped(record) => {
438            slug = record.project.clone();
439            local = Some(Mapped {
440                config_dir: config_dir.to_path_buf(),
441                record,
442            });
443        }
444        Fit::LegacyOther(theirs) => {
445            return Err(OtherServer(format!(
446                "{path} is for {theirs}, but this command uses {have}. To use that server, set SEMORA_URL or pass --server {theirs}."
447            ))
448            .into());
449        }
450        Fit::Other => return Err(OtherServer(other_server_message(&path, &have)).into()),
451    }
452    Ok(Link {
453        root: file.root,
454        server: have,
455        slug,
456        file_slug: file.slug,
457        local,
458    })
459}
460
461/// The file names this server now, so records on this machine that sent its project here from
462/// another server are no longer used. They are dropped; one that used another project id is
463/// worth a word, since the file's id is used from now on.
464async fn drop_shadowed(client: &Client, config_dir: &Path, file: &FileLink, path: &str) {
465    let Ok(id) = server_id::of(client, config_dir, false).await else {
466        return;
467    };
468    match local_links::remove_shadowed(config_dir, &id, &file.slug, &file.root) {
469        Ok(gone) => {
470            for g in gone.iter().filter(|g| g.project != file.slug) {
471                output::warn(format!(
472                    "{path} now names this server, so this machine's link to project '{}' is dropped; the file's project '{}' is used.",
473                    g.project, file.slug
474                ));
475            }
476        }
477        Err(e) => output::warn(format!("{e:#}")),
478    }
479}
480
481/// The file names another server's id. It can't say which server that is, so the message names
482/// only this one and the ways out.
483fn other_server_message(path: &str, have: &str) -> String {
484    format!(
485        "{path} is for another Semora server than the one this CLI uses ({have}).
486The file does not say which server it is. Either:
487  - log in to the project's server: semora login --server ADDRESS (ask whoever set up the project)
488  - use your server for this project on this machine only: semora project link
489    (your documents drift from everyone else's; nobody else sees them)
490  - point .semora at your server: semora project link --rewrite
491    (once you commit it, every other checkout stops working until it does the same)"
492    )
493}
494
495impl Link {
496    /// The pushed path relative to the project root, joined with `/`.
497    pub fn relative_path(&self, target: &Path) -> anyhow::Result<String> {
498        let full = std::fs::canonicalize(target)
499            .with_context(|| format!("cannot read {}", shown(target)))?;
500        let rel = full.strip_prefix(&self.root).with_context(|| {
501            format!(
502                "{} is not inside the project at {}",
503                shown(&full),
504                shown(&self.root)
505            )
506        })?;
507        let mut parts = Vec::new();
508        for c in rel.components() {
509            let s = c.as_os_str().to_str().with_context(|| {
510                format!(
511                    "{} isn't valid Unicode, so it can't be a project path",
512                    shown(&full)
513                )
514            })?;
515            parts.push(s.to_string());
516        }
517        if parts.is_empty() {
518            bail!(
519                "{} is here: push a file or a folder inside this project, not the project root.",
520                shown(&self.root.join(FILE))
521            );
522        }
523        Ok(parts.join("/"))
524    }
525}
526
527/// The branch of the git checkout the path is in, read from `HEAD` (no `git` process).
528/// A worktree's `.git` is a file that names its git folder; a relative name is relative to
529/// the folder that holds that file. The walk starts from `std::path::absolute`, not
530/// `canonicalize`: on Windows a canonical path is a `\\?\` path, and those do not resolve `..`
531/// or `/`, so the relative name is joined and cleaned up lexically instead.
532pub fn git_branch(path: &Path) -> Option<String> {
533    let full = std::path::absolute(path).ok()?;
534    let mut dir = start_dir(&full);
535    while let Some(d) = dir {
536        let dot = d.join(".git");
537        if dot.is_dir() {
538            return branch_in(&dot);
539        }
540        if dot.is_file() {
541            let text = std::fs::read_to_string(&dot).ok()?;
542            let target = text
543                .lines()
544                .find_map(|l| l.trim().strip_prefix("gitdir:"))?
545                .trim();
546            let gitdir = if Path::new(target).is_absolute() {
547                PathBuf::from(target)
548            } else {
549                lexical(&d.join(target))
550            };
551            return branch_in(&gitdir);
552        }
553        dir = d.parent();
554    }
555    None
556}
557
558/// `.` and `..` resolved without touching the file system.
559fn lexical(path: &Path) -> PathBuf {
560    let mut out = PathBuf::new();
561    for c in path.components() {
562        match c {
563            Component::CurDir => {}
564            Component::ParentDir => {
565                if !out.pop() {
566                    out.push("..");
567                }
568            }
569            other => out.push(other.as_os_str()),
570        }
571    }
572    out
573}
574
575fn branch_in(gitdir: &Path) -> Option<String> {
576    let head = std::fs::read_to_string(gitdir.join("HEAD")).ok()?;
577    head.trim()
578        .strip_prefix("ref: refs/heads/")
579        .map(str::to_string)
580        .filter(|b| !b.is_empty())
581}
582
583/// Where a push came from: `machine ยท branch`, or just the machine.
584pub fn origin_for(path: &Path) -> String {
585    let machine = crate::commands::login::hostname();
586    match git_branch(path) {
587        Some(b) => format!("{machine} \u{b7} {b}"),
588        None => machine,
589    }
590}
591
592#[cfg(test)]
593mod tests {
594    use super::*;
595
596    fn dir() -> tempfile::TempDir {
597        tempfile::tempdir().unwrap()
598    }
599
600    /// The link a command gets for a file that is for its server.
601    fn linked(f: FileLink) -> Link {
602        Link {
603            root: f.root,
604            server: "https://a.example.com".into(),
605            slug: f.slug.clone(),
606            file_slug: f.slug,
607            local: None,
608        }
609    }
610
611    fn put(root: &Path, rel: &str, text: &str) -> PathBuf {
612        let p = root.join(rel);
613        std::fs::create_dir_all(p.parent().unwrap()).unwrap();
614        std::fs::write(&p, text).unwrap();
615        p
616    }
617
618    #[test]
619    fn slugs_follow_the_rule() {
620        let (x40, x41) = ("x".repeat(40), "x".repeat(41));
621        for ok in ["semora", "a", "9-a", x40.as_str()] {
622            assert!(valid_slug(ok), "{ok}");
623        }
624        for bad in ["", "Semora", "-x", "a b", x41.as_str()] {
625            assert!(!valid_slug(bad), "{bad}");
626        }
627    }
628
629    #[test]
630    fn a_slug_is_made_from_a_directory_name() {
631        assert_eq!(slug_from("My Docs_v2").as_deref(), Some("my-docs-v2"));
632        assert_eq!(slug_from(".tmpAbC123").as_deref(), Some("tmpabc123"));
633        assert_eq!(slug_from("--a--b--").as_deref(), Some("a-b"));
634        assert_eq!(slug_from("semora").as_deref(), Some("semora"));
635        assert_eq!(slug_from(&"a".repeat(50)).unwrap().len(), 40);
636        let a39 = "a".repeat(39);
637        assert_eq!(
638            slug_from(&format!("{a39}-b")).as_deref(),
639            Some(a39.as_str()),
640            "no dash left at the cut"
641        );
642        assert_eq!(slug_from("___"), None);
643        assert_eq!(slug_from(""), None);
644    }
645
646    #[test]
647    fn dot_semora_is_parsed_and_checked() {
648        let d = dir();
649        put(
650            d.path(),
651            ".semora",
652            "# note\nserver = \"https://Semora.example.com/\"\nproject = \"semora\"\nextra = 1\n",
653        );
654        let link = read_at(d.path()).unwrap().unwrap();
655        assert_eq!(link.slug, "semora");
656        assert_eq!(
657            link.server,
658            ServerRef::Url("https://semora.example.com".into()),
659            "the origin, whatever the spelling"
660        );
661        put(
662            d.path(),
663            ".semora",
664            "server = \" 3F9C0E7A5B1D4E2F8A6C9B0D1E2F3A4B \"\nproject = \"semora\"\n",
665        );
666        assert_eq!(
667            read_at(d.path()).unwrap().unwrap().server,
668            ServerRef::Id("3f9c0e7a5b1d4e2f8a6c9b0d1e2f3a4b".into()),
669            "an id, normalized"
670        );
671
672        for (text, why) in [
673            ("project = \"x\"\n", "server"),
674            ("server = \"https://a.example.com\"\n", "project"),
675            (
676                "server = \"https://a.example.com\"\nproject = \"Bad Id\"\n",
677                "valid project id",
678            ),
679            ("server = \n", "parsing"),
680        ] {
681            put(d.path(), ".semora", text);
682            let err = read_at(d.path()).unwrap_err().to_string();
683            assert!(
684                err.contains(why) && err.contains(".semora"),
685                "{text:?} -> {err}"
686            );
687        }
688        std::fs::remove_file(d.path().join(".semora")).unwrap();
689        assert_eq!(read_at(d.path()).unwrap(), None);
690    }
691
692    #[test]
693    fn find_walks_up_from_the_path_and_the_nearest_file_wins() {
694        let d = dir();
695        put(
696            d.path(),
697            ".semora",
698            "server = \"https://a.example.com\"\nproject = \"outer\"\n",
699        );
700        put(
701            d.path(),
702            "inner/.semora",
703            "server = \"https://a.example.com\"\nproject = \"inner\"\n",
704        );
705        let file = put(d.path(), "docs/deep/x.md", "# x");
706        put(d.path(), "inner/y.md", "# y");
707        assert_eq!(find(&file).unwrap().unwrap().slug, "outer");
708        assert_eq!(
709            find(&d.path().join("docs")).unwrap().unwrap().slug,
710            "outer",
711            "a folder"
712        );
713        assert_eq!(
714            find(&d.path().join("inner/y.md")).unwrap().unwrap().slug,
715            "inner"
716        );
717        assert_eq!(
718            find(&d.path().join("inner")).unwrap().unwrap().slug,
719            "inner",
720            "the folder's own file"
721        );
722        let lonely = tempfile::tempdir().unwrap();
723        let f = put(lonely.path(), "z.md", "# z");
724        assert_eq!(find(&f).unwrap(), None);
725    }
726
727    #[test]
728    fn the_key_path_is_relative_with_forward_slashes() {
729        let d = dir();
730        put(
731            d.path(),
732            ".semora",
733            "server = \"https://a.example.com\"\nproject = \"p\"\n",
734        );
735        let file = put(d.path(), "docs/deep/x.md", "# x");
736        let link = linked(find(&file).unwrap().unwrap());
737        assert_eq!(link.relative_path(&file).unwrap(), "docs/deep/x.md");
738        assert_eq!(link.relative_path(&d.path().join("docs")).unwrap(), "docs");
739        let err = link.relative_path(d.path()).unwrap_err().to_string();
740        assert!(err.contains("not the project root"), "{err}");
741    }
742
743    #[test]
744    fn the_message_for_another_servers_id_names_only_this_one_and_the_ways_out() {
745        let m = other_server_message("/r/.semora", "https://mine.example.com");
746        for part in [
747            "/r/.semora",
748            "https://mine.example.com",
749            "semora login --server ADDRESS",
750            "semora project link",
751            "semora project link --rewrite",
752        ] {
753            assert!(m.contains(part), "{part}: {m}");
754        }
755        assert!(
756            !m.contains("--id") && !m.contains("--new"),
757            "push flags are push's to mention, not every command's: {m}"
758        );
759    }
760
761    #[test]
762    fn write_makes_a_file_that_reads_back() {
763        let d = dir();
764        let id = "3f9c0e7a5b1d4e2f8a6c9b0d1e2f3a4b";
765        let path = write(d.path(), id, "semora").unwrap();
766        let text = std::fs::read_to_string(&path).unwrap();
767        assert!(text.starts_with("# Semora project."), "{text}");
768        let link = read_at(d.path()).unwrap().unwrap();
769        assert_eq!(
770            (link.server, link.slug.as_str()),
771            (ServerRef::Id(id.into()), "semora")
772        );
773    }
774
775    #[test]
776    fn paths_in_messages_lose_the_windows_verbatim_prefix() {
777        assert_eq!(
778            shown(Path::new(r"\\?\C:\Development\x\.semora")),
779            r"C:\Development\x\.semora"
780        );
781        assert_eq!(shown(Path::new(r"\\?\UNC\nas\share\x")), r"\\nas\share\x");
782        assert_eq!(shown(Path::new("/home/a/x")), "/home/a/x");
783    }
784
785    #[test]
786    fn dots_are_resolved_without_the_file_system() {
787        assert_eq!(lexical(Path::new("/a/b/../c/./d")), PathBuf::from("/a/c/d"));
788        assert_eq!(lexical(Path::new("a/../../b")), PathBuf::from("../b"));
789    }
790
791    #[test]
792    fn the_branch_comes_from_head() {
793        let d = dir();
794        put(d.path(), ".git/HEAD", "ref: refs/heads/feat/projects\n");
795        let file = put(d.path(), "docs/x.md", "# x");
796        assert_eq!(git_branch(&file).as_deref(), Some("feat/projects"));
797        put(
798            d.path(),
799            ".git/HEAD",
800            "0123456789abcdef0123456789abcdef01234567\n",
801        );
802        assert_eq!(git_branch(&file), None, "a detached HEAD has no branch");
803    }
804
805    #[test]
806    fn a_worktree_points_at_its_git_folder_absolute_or_relative() {
807        let d = dir();
808        let main = put(
809            d.path(),
810            "main/.git/worktrees/wt/HEAD",
811            "ref: refs/heads/feat/wt\n",
812        );
813        let abs = main.parent().unwrap().to_string_lossy().replace('\\', "/");
814        put(d.path(), "abs/.git", &format!("gitdir: {abs}\n"));
815        let file = put(d.path(), "abs/x.md", "# x");
816        assert_eq!(
817            git_branch(&file).as_deref(),
818            Some("feat/wt"),
819            "absolute gitdir"
820        );
821        // Relative to the folder that holds the `.git` file, not to the current directory.
822        put(d.path(), "rel/.git", "gitdir: ../main/.git/worktrees/wt\n");
823        let file = put(d.path(), "rel/x.md", "# x");
824        assert_eq!(
825            git_branch(&file).as_deref(),
826            Some("feat/wt"),
827            "relative gitdir"
828        );
829    }
830
831    #[test]
832    fn a_verbatim_path_still_reads_a_relative_gitdir() {
833        // `watch` passes a canonical path; on Windows that is a `\\?\` path, where `/` is not a
834        // separator, so a relative `gitdir` must not be joined onto it as it is.
835        let d = dir();
836        put(
837            d.path(),
838            "main/.git/worktrees/wt/HEAD",
839            "ref: refs/heads/feat/wt\n",
840        );
841        put(d.path(), "rel/.git", "gitdir: ../main/.git/worktrees/wt\n");
842        let file = put(d.path(), "rel/x.md", "# x");
843        let canonical = std::fs::canonicalize(&file).unwrap();
844        if cfg!(windows) {
845            assert!(
846                canonical.to_string_lossy().starts_with(r"\\?\"),
847                "{canonical:?}"
848            );
849        }
850        assert_eq!(git_branch(&canonical).as_deref(), Some("feat/wt"));
851    }
852
853    #[test]
854    fn origin_is_the_machine_and_the_branch_when_there_is_one() {
855        let d = dir();
856        let file = put(d.path(), "x.md", "# x");
857        let machine = origin_for(&file);
858        assert!(!machine.contains('\u{b7}'), "no branch: {machine}");
859        put(d.path(), ".git/HEAD", "ref: refs/heads/main\n");
860        let with = origin_for(&file);
861        assert_eq!(with, format!("{machine} \u{b7} main"));
862    }
863}