1use 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 pub file_server: String,
18 pub file_project: String,
20 pub origin: String,
22 pub server_id: String,
23 pub project: String,
25 #[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
60fn 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
82pub 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
90pub 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
102pub 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}