char/sorcery

static-files based git repo viewer

git clone https://git.t4t.associates/char/sorcery

Charlotte Somadd sorcery-ssh with sorcery-ssh-tui8f07a0e

main
6.1 KiB203 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 find(root: &Path, id: &str) -> Result<Repo> {
69    let (user, name) = parse_id(id)?;
70    discover(root)?
71        .into_iter()
72        .find(|repo| repo.user == user && repo.name == name)
73        .with_context(|| format!("repository {user}/{name} not found"))
74}
75
76pub fn set_description(repo: &Repo, text: &str) -> Result<()> {
77    if text.contains(['\r', '\n']) {
78        bail!("descriptions must fit on one line");
79    }
80    let text = text.trim();
81    let path = repo.path.join("description");
82    if text.is_empty() {
83        match fs::remove_file(&path) {
84            Ok(()) => {}
85            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
86            Err(error) => {
87                return Err(error).with_context(|| format!("removing {}", path.display()));
88            }
89        }
90    } else {
91        fs::write(&path, format!("{text}\n"))
92            .with_context(|| format!("writing {}", path.display()))?;
93    }
94    Ok(())
95}
96
97fn parse_id(id: &str) -> Result<(&str, &str)> {
98    let (user, name) = id
99        .split_once('/')
100        .with_context(|| format!("repository must be written as user/repo, got {id:?}"))?;
101    let name = name.strip_suffix(".git").unwrap_or(name);
102    if user.is_empty()
103        || name.is_empty()
104        || user.starts_with('.')
105        || name.starts_with('.')
106        || name.contains('/')
107    {
108        bail!("repository must be written as user/repo, got {id:?}");
109    }
110    Ok((user, name))
111}
112
113fn description(path: &Path) -> Option<String> {
114    fs::read_to_string(path.join("description"))
115        .ok()
116        .map(|description| description.trim().to_owned())
117        .filter(|description| {
118            !description.is_empty() && !description.starts_with("Unnamed repository")
119        })
120}
121
122fn head(path: &Path) -> Option<String> {
123    let head = fs::read_to_string(path.join("HEAD")).ok()?;
124    let head = head.trim();
125    Some(
126        head.strip_prefix("ref: refs/heads/")
127            .unwrap_or(head)
128            .to_owned(),
129    )
130    .filter(|head| !head.is_empty())
131}
132
133fn sorted_dirs(path: &Path) -> Result<Vec<PathBuf>> {
134    let mut dirs = fs::read_dir(path)
135        .with_context(|| format!("reading {}", path.display()))?
136        .filter_map(|entry| entry.ok())
137        .filter_map(|entry| entry.file_type().ok()?.is_dir().then_some(entry.path()))
138        .collect::<Vec<_>>();
139    dirs.sort();
140    Ok(dirs)
141}
142
143#[cfg(test)]
144mod tests {
145    use std::fs;
146
147    use tempfile::TempDir;
148
149    use super::*;
150
151    fn bare_repo(root: &Path, user: &str, name: &str) -> PathBuf {
152        let path = root.join(user).join(name);
153        fs::create_dir_all(&path).unwrap();
154        fs::write(path.join("HEAD"), "ref: refs/heads/main\n").unwrap();
155        path
156    }
157
158    #[test]
159    fn discovers_bare_repositories_and_descriptions() {
160        let temp = TempDir::new().unwrap();
161        let first = bare_repo(temp.path(), "alice", "first.git");
162        fs::write(first.join("description"), "first repo\n").unwrap();
163        bare_repo(temp.path(), "alice", ".hidden");
164        bare_repo(temp.path(), ".hidden", "repo");
165        fs::create_dir_all(temp.path().join("alice/not-a-repo")).unwrap();
166
167        let repos = discover(temp.path()).unwrap();
168        assert_eq!(repos.len(), 1);
169        assert_eq!(repos[0].id(), "alice/first");
170        assert_eq!(repos[0].description.as_deref(), Some("first repo"));
171        assert_eq!(repos[0].head.as_deref(), Some("main"));
172    }
173
174    #[test]
175    fn description_round_trip_and_removal() {
176        let temp = TempDir::new().unwrap();
177        bare_repo(temp.path(), "alice", "first");
178        let repo = find(temp.path(), "alice/first.git").unwrap();
179
180        set_description(&repo, "  hello  ").unwrap();
181        assert_eq!(
182            find(temp.path(), "alice/first")
183                .unwrap()
184                .description
185                .as_deref(),
186            Some("hello")
187        );
188
189        set_description(&repo, "").unwrap();
190        assert_eq!(find(temp.path(), "alice/first").unwrap().description, None);
191    }
192
193    #[test]
194    fn rejects_paths_and_multiline_descriptions() {
195        let temp = TempDir::new().unwrap();
196        bare_repo(temp.path(), "alice", "first");
197        let repo = find(temp.path(), "alice/first").unwrap();
198
199        assert!(find(temp.path(), "../alice/first").is_err());
200        assert!(find(temp.path(), "alice/first/other").is_err());
201        assert!(set_description(&repo, "one\ntwo").is_err());
202    }
203}