Skip to main content

semora_cli/
local_links.rs

1//! Records on this machine that a `.semora` naming another server is used with this CLI's
2//! server (`semora project link`). They live in `project-links.toml` in the config folder, never
3//! in the checkout, so nobody else's copy of the project changes.
4
5use std::path::{Path, PathBuf};
6
7use anyhow::Context;
8use serde::{Deserialize, Serialize};
9
10use crate::cachefile;
11
12const FILE: &str = "project-links.toml";
13
14#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
15pub struct LocalLink {
16    /// The file's `server` value, normalized: the id, or the origin of a URL from before ids.
17    pub file_server: String,
18    /// The project id the file names.
19    pub file_project: String,
20    /// The server used instead.
21    pub origin: String,
22    pub server_id: String,
23    /// The project id used on that server.
24    pub project: String,
25    /// The folder `semora project link` ran for. Other checkouts of the same repository use the
26    /// record too; only this folder drops it once its file names this server.
27    #[serde(default, skip_serializing_if = "Option::is_none")]
28    pub root: Option<PathBuf>,
29}
30
31#[derive(Debug, Default, Serialize, Deserialize)]
32struct Links {
33    #[serde(default)]
34    link: Vec<LocalLink>,
35}
36
37fn parse(dir: &Path, text: &str) -> anyhow::Result<Links> {
38    toml::from_str(text)
39        .with_context(|| format!("reading {}: fix or delete it", dir.join(FILE).display()))
40}
41
42pub fn load_all(dir: &Path) -> anyhow::Result<Vec<LocalLink>> {
43    match std::fs::read_to_string(dir.join(FILE)) {
44        Ok(text) => Ok(parse(dir, &text)?.link),
45        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Vec::new()),
46        Err(e) => Err(e).with_context(|| format!("reading {}", dir.join(FILE).display())),
47    }
48}
49
50pub fn find(
51    dir: &Path,
52    file_server: &str,
53    file_project: &str,
54) -> anyhow::Result<Option<LocalLink>> {
55    Ok(load_all(dir)?
56        .into_iter()
57        .find(|l| l.file_server == file_server && l.file_project == file_project))
58}
59
60/// Rewrites the file with `change` applied to its entries. A broken file is reported, not
61/// replaced, so a hand edit is never lost.
62fn change<T>(dir: &Path, f: impl FnOnce(&mut Vec<LocalLink>) -> T) -> anyhow::Result<T> {
63    let mut out = None;
64    let mut failed = None;
65    cachefile::rewrite(dir, FILE, |text| match parse(dir, text) {
66        Ok(mut links) => {
67            out = Some(f(&mut links.link));
68            toml::to_string(&links).unwrap_or_else(|_| text.to_string())
69        }
70        Err(e) => {
71            failed = Some(e);
72            text.to_string()
73        }
74    })?;
75    match (out, failed) {
76        (Some(v), _) => Ok(v),
77        (None, Some(e)) => Err(e),
78        (None, None) => unreachable!("the change runs once"),
79    }
80}
81
82/// Saves `link`, replacing the entry for the same file.
83pub fn save(dir: &Path, link: LocalLink) -> anyhow::Result<()> {
84    change(dir, |all| {
85        all.retain(|l| !(l.file_server == link.file_server && l.file_project == link.file_project));
86        all.push(link);
87    })
88}
89
90/// Drops the entry for the file. `false` when there was none.
91pub fn remove(dir: &Path, file_server: &str, file_project: &str) -> anyhow::Result<bool> {
92    if find(dir, file_server, file_project)?.is_none() {
93        return Ok(false);
94    }
95    change(dir, |all| {
96        let before = all.len();
97        all.retain(|l| !(l.file_server == file_server && l.file_project == file_project));
98        all.len() < before
99    })
100}
101
102/// The file in `root` named another server and now names this one (`server_id`), so the entries
103/// made for that folder that sent its project here are no longer used: drops them and returns
104/// them. An entry made for another folder belongs to another checkout, which may be another
105/// repository with the same project id, and is kept.
106pub fn remove_shadowed(
107    dir: &Path,
108    server_id: &str,
109    file_project: &str,
110    root: &Path,
111) -> anyhow::Result<Vec<LocalLink>> {
112    let shadowed = |l: &LocalLink| {
113        l.server_id == server_id
114            && l.file_project == file_project
115            && l.file_server != server_id
116            && l.root.as_deref() == Some(root)
117    };
118    if !load_all(dir)?.iter().any(shadowed) {
119        return Ok(Vec::new());
120    }
121    change(dir, |all| {
122        let (gone, kept) = std::mem::take(all).into_iter().partition(shadowed);
123        *all = kept;
124        gone
125    })
126}
127
128#[cfg(test)]
129mod tests {
130    use super::*;
131
132    fn link(file_server: &str, project: &str) -> LocalLink {
133        LocalLink {
134            file_server: file_server.into(),
135            file_project: "proj".into(),
136            origin: "https://mine.example.com".into(),
137            server_id: "1".repeat(32),
138            project: project.into(),
139            root: Some(PathBuf::from("/r")),
140        }
141    }
142
143    #[test]
144    fn links_are_saved_found_replaced_and_removed() {
145        let d = tempfile::tempdir().unwrap();
146        assert_eq!(find(d.path(), "a", "proj").unwrap(), None);
147        save(d.path(), link("a", "proj")).unwrap();
148        save(d.path(), link("b", "proj")).unwrap();
149        save(d.path(), link("a", "mine")).unwrap();
150        assert_eq!(
151            find(d.path(), "a", "proj").unwrap().unwrap().project,
152            "mine"
153        );
154        assert_eq!(load_all(d.path()).unwrap().len(), 2);
155        assert!(remove(d.path(), "a", "proj").unwrap());
156        assert!(!remove(d.path(), "a", "proj").unwrap());
157        assert_eq!(load_all(d.path()).unwrap().len(), 1);
158    }
159
160    #[test]
161    fn shadowed_links_are_dropped() {
162        let d = tempfile::tempdir().unwrap();
163        save(d.path(), link("a", "mine")).unwrap();
164        let other = remove_shadowed(d.path(), &"1".repeat(32), "proj", Path::new("/s")).unwrap();
165        assert!(other.is_empty(), "another folder's link is kept");
166        let gone = remove_shadowed(d.path(), &"1".repeat(32), "proj", Path::new("/r")).unwrap();
167        assert_eq!(gone.len(), 1);
168        assert!(load_all(d.path()).unwrap().is_empty());
169    }
170
171    #[test]
172    fn a_broken_links_file_names_itself() {
173        let d = tempfile::tempdir().unwrap();
174        std::fs::write(d.path().join("project-links.toml"), "[[link]\n").unwrap();
175        let err = format!("{:#}", load_all(d.path()).unwrap_err());
176        assert!(
177            err.contains("project-links.toml") && err.contains("fix or delete it"),
178            "{err}"
179        );
180        let err = format!("{:#}", save(d.path(), link("a", "proj")).unwrap_err());
181        assert!(err.contains("fix or delete it"), "{err}");
182        assert_eq!(
183            std::fs::read_to_string(d.path().join("project-links.toml")).unwrap(),
184            "[[link]\n",
185            "a broken file is left as it is"
186        );
187    }
188}