forks/sorcery

git clone https://git.gae.moe/forks/sorcery

Charlotte Somsorcery-ssh: refactor3356bff

darwin-flake
4.9 KiB165 linesraw
1use std::collections::BTreeMap;
2use std::fs;
3use std::path::{Path, PathBuf};
4
5use anyhow::{Context, Result, bail};
6
7#[derive(Clone, Debug, Eq, PartialEq)]
8pub struct Repo {
9    pub user: String,
10    pub name: String,
11    pub path: PathBuf,
12    pub description: Option<String>,
13    pub head: Option<String>,
14}
15
16impl Repo {
17    pub fn id(&self) -> String {
18        format!("{}/{}", self.user, self.name)
19    }
20}
21
22pub fn discover(root: &Path) -> Result<Vec<Repo>> {
23    let root = root
24        .canonicalize()
25        .with_context(|| format!("canonicalizing repository root {}", root.display()))?;
26    let mut found = BTreeMap::new();
27
28    for user in sorted_dirs(&root)? {
29        let Some(user_name) = user.file_name().and_then(|name| name.to_str()) else {
30            continue;
31        };
32        if user_name.starts_with('.') {
33            continue;
34        }
35        for path in sorted_dirs(&user)? {
36            let Some(file_name) = path
37                .file_name()
38                .and_then(|name| name.to_str())
39                .map(str::to_owned)
40            else {
41                continue;
42            };
43            if file_name.starts_with('.') || !path.join("HEAD").is_file() {
44                continue;
45            }
46            let name = file_name
47                .strip_suffix(".git")
48                .unwrap_or(&file_name)
49                .to_owned();
50            if name.is_empty() {
51                continue;
52            }
53            found
54                .entry((user_name.to_owned(), name.clone()))
55                .or_insert_with(|| Repo {
56                    user: user_name.to_owned(),
57                    name,
58                    description: description(&path),
59                    head: head(&path),
60                    path,
61                });
62        }
63    }
64
65    Ok(found.into_values().collect())
66}
67
68pub fn set_description(repo: &Repo, text: &str) -> Result<()> {
69    if text.contains(['\r', '\n']) {
70        bail!("descriptions must fit on one line");
71    }
72    let text = text.trim();
73    let path = repo.path.join("description");
74    if text.is_empty() {
75        match fs::remove_file(&path) {
76            Ok(()) => {}
77            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
78            Err(error) => {
79                return Err(error).with_context(|| format!("removing {}", path.display()));
80            }
81        }
82    } else {
83        fs::write(&path, format!("{text}\n"))
84            .with_context(|| format!("writing {}", path.display()))?;
85    }
86    Ok(())
87}
88
89fn description(path: &Path) -> Option<String> {
90    fs::read_to_string(path.join("description"))
91        .ok()
92        .map(|description| description.trim().to_owned())
93        .filter(|description| {
94            !description.is_empty() && !description.starts_with("Unnamed repository")
95        })
96}
97
98fn head(path: &Path) -> Option<String> {
99    let head = fs::read_to_string(path.join("HEAD")).ok()?;
100    let head = head.trim();
101    Some(
102        head.strip_prefix("ref: refs/heads/")
103            .unwrap_or(head)
104            .to_owned(),
105    )
106    .filter(|head| !head.is_empty())
107}
108
109fn sorted_dirs(path: &Path) -> Result<Vec<PathBuf>> {
110    let mut dirs = fs::read_dir(path)
111        .with_context(|| format!("reading {}", path.display()))?
112        .filter_map(|entry| entry.ok())
113        .filter_map(|entry| entry.file_type().ok()?.is_dir().then_some(entry.path()))
114        .collect::<Vec<_>>();
115    dirs.sort();
116    Ok(dirs)
117}
118
119#[cfg(test)]
120mod tests {
121    use std::fs;
122
123    use tempfile::TempDir;
124
125    use super::*;
126
127    fn bare_repo(root: &Path, user: &str, name: &str) -> PathBuf {
128        let path = root.join(user).join(name);
129        fs::create_dir_all(&path).unwrap();
130        fs::write(path.join("HEAD"), "ref: refs/heads/main\n").unwrap();
131        path
132    }
133
134    #[test]
135    fn discovers_bare_repositories_and_descriptions() {
136        let temp = TempDir::new().unwrap();
137        let first = bare_repo(temp.path(), "alice", "first.git");
138        fs::write(first.join("description"), "first repo\n").unwrap();
139        bare_repo(temp.path(), "alice", ".hidden");
140        bare_repo(temp.path(), ".hidden", "repo");
141        fs::create_dir_all(temp.path().join("alice/not-a-repo")).unwrap();
142
143        let repos = discover(temp.path()).unwrap();
144        assert_eq!(repos.len(), 1);
145        assert_eq!(repos[0].id(), "alice/first");
146        assert_eq!(repos[0].description.as_deref(), Some("first repo"));
147        assert_eq!(repos[0].head.as_deref(), Some("main"));
148    }
149
150    #[test]
151    fn description_round_trip_and_removal() {
152        let temp = TempDir::new().unwrap();
153        bare_repo(temp.path(), "alice", "first");
154        let description = || discover(temp.path()).unwrap()[0].description.clone();
155        let repo = &discover(temp.path()).unwrap()[0];
156
157        set_description(repo, "  hello  ").unwrap();
158        assert_eq!(description().as_deref(), Some("hello"));
159
160        set_description(repo, "").unwrap();
161        assert_eq!(description(), None);
162
163        assert!(set_description(repo, "one\ntwo").is_err());
164    }
165}