1use std::path::{Component, Path, PathBuf};
14
15use anyhow::{Context, bail};
16use serde::Deserialize;
17
18use crate::client::Client;
19use crate::config::{origin, parse_server};
20
21pub const FILE: &str = ".semora";
22
23pub fn shown(path: &Path) -> String {
25 let s = path.display().to_string();
26 match s.strip_prefix(r"\\?\") {
27 Some(rest) => match rest.strip_prefix(r"UNC\") {
28 Some(share) => format!(r"\\{share}"),
29 None => rest.to_string(),
30 },
31 None => s,
32 }
33}
34
35#[derive(Debug)]
38pub struct OtherServer(pub String);
39
40impl std::fmt::Display for OtherServer {
41 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
42 f.write_str(&self.0)
43 }
44}
45
46impl std::error::Error for OtherServer {}
47
48#[derive(Debug, Clone, PartialEq, Eq)]
49pub struct Link {
50 pub root: PathBuf,
52 pub server: String,
54 pub slug: String,
55}
56
57#[derive(Deserialize)]
58struct Raw {
59 server: Option<String>,
60 project: Option<String>,
61}
62
63pub fn valid_slug(s: &str) -> bool {
65 (1..=40).contains(&s.len())
66 && s.bytes()
67 .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
68 && !s.starts_with('-')
69}
70
71pub fn slug_from(dir_name: &str) -> Option<String> {
74 let mut out = String::new();
75 for c in dir_name.to_lowercase().chars() {
76 if c.is_ascii_lowercase() || c.is_ascii_digit() {
77 out.push(c);
78 } else if !out.ends_with('-') {
79 out.push('-');
80 }
81 }
82 let cut: String = out.trim_matches('-').chars().take(40).collect();
83 let slug = cut.trim_matches('-').to_string();
84 valid_slug(&slug).then_some(slug)
85}
86
87pub fn read_at(dir: &Path) -> anyhow::Result<Option<Link>> {
89 let file = dir.join(FILE);
90 if !file.is_file() {
91 return Ok(None);
92 }
93 let text =
94 std::fs::read_to_string(&file).with_context(|| format!("reading {}", shown(&file)))?;
95 let raw: Raw = toml::from_str(&text).with_context(|| format!("parsing {}", shown(&file)))?;
96 let server = raw
97 .server
98 .filter(|s| !s.trim().is_empty())
99 .with_context(|| format!("{} needs a `server` line", shown(&file)))?;
100 let slug = raw
101 .project
102 .filter(|s| !s.trim().is_empty())
103 .with_context(|| format!("{} needs a `project` line", shown(&file)))?;
104 if !valid_slug(&slug) {
105 bail!(
106 "{}: `{slug}` is not a valid project id (lowercase letters, digits and dashes, up to 40, starting with a letter or digit)",
107 shown(&file)
108 );
109 }
110 let server =
111 parse_server(&server).with_context(|| format!("{}: bad `server`", shown(&file)))?;
112 Ok(Some(Link {
113 root: dir.to_path_buf(),
114 server: origin(&server),
115 slug,
116 }))
117}
118
119fn start_dir(full: &Path) -> Option<&Path> {
121 if full.is_dir() {
122 Some(full)
123 } else {
124 full.parent()
125 }
126}
127
128pub fn find(path: &Path) -> anyhow::Result<Option<Link>> {
131 let full =
132 std::fs::canonicalize(path).with_context(|| format!("cannot read {}", shown(path)))?;
133 let mut dir = start_dir(&full);
134 while let Some(d) = dir {
135 if let Some(link) = read_at(d)? {
136 return Ok(Some(link));
137 }
138 dir = d.parent();
139 }
140 Ok(None)
141}
142
143pub fn write(dir: &Path, server: &str, slug: &str) -> anyhow::Result<PathBuf> {
145 let file = dir.join(FILE);
146 let text = format!(
147 "# Semora project. Commit this file so every checkout publishes to the same place.\nserver = \"{server}\"\nproject = \"{slug}\"\n"
148 );
149 std::fs::write(&file, text).with_context(|| format!("writing {}", shown(&file)))?;
150 Ok(file)
151}
152
153impl Link {
154 pub fn check_origin(&self, have: &str) -> anyhow::Result<()> {
156 if self.server == have {
157 return Ok(());
158 }
159 Err(OtherServer(format!(
160 "{} is for {}, but this command uses {have}. To use that server, set SEMORA_URL or pass --server {}.",
161 shown(&self.root.join(FILE)),
162 self.server,
163 self.server
164 ))
165 .into())
166 }
167
168 pub fn check_server(&self, client: &Client) -> anyhow::Result<()> {
169 self.check_origin(&client.origin())
170 }
171
172 pub fn relative_path(&self, target: &Path) -> anyhow::Result<String> {
174 let full = std::fs::canonicalize(target)
175 .with_context(|| format!("cannot read {}", shown(target)))?;
176 let rel = full.strip_prefix(&self.root).with_context(|| {
177 format!(
178 "{} is not inside the project at {}",
179 shown(&full),
180 shown(&self.root)
181 )
182 })?;
183 let mut parts = Vec::new();
184 for c in rel.components() {
185 let s = c.as_os_str().to_str().with_context(|| {
186 format!(
187 "{} isn't valid Unicode, so it can't be a project path",
188 shown(&full)
189 )
190 })?;
191 parts.push(s.to_string());
192 }
193 if parts.is_empty() {
194 bail!(
195 "{} is here: push a file or a folder inside this project, not the project root.",
196 shown(&self.root.join(FILE))
197 );
198 }
199 Ok(parts.join("/"))
200 }
201}
202
203pub fn git_branch(path: &Path) -> Option<String> {
209 let full = std::path::absolute(path).ok()?;
210 let mut dir = start_dir(&full);
211 while let Some(d) = dir {
212 let dot = d.join(".git");
213 if dot.is_dir() {
214 return branch_in(&dot);
215 }
216 if dot.is_file() {
217 let text = std::fs::read_to_string(&dot).ok()?;
218 let target = text
219 .lines()
220 .find_map(|l| l.trim().strip_prefix("gitdir:"))?
221 .trim();
222 let gitdir = if Path::new(target).is_absolute() {
223 PathBuf::from(target)
224 } else {
225 lexical(&d.join(target))
226 };
227 return branch_in(&gitdir);
228 }
229 dir = d.parent();
230 }
231 None
232}
233
234fn lexical(path: &Path) -> PathBuf {
236 let mut out = PathBuf::new();
237 for c in path.components() {
238 match c {
239 Component::CurDir => {}
240 Component::ParentDir => {
241 if !out.pop() {
242 out.push("..");
243 }
244 }
245 other => out.push(other.as_os_str()),
246 }
247 }
248 out
249}
250
251fn branch_in(gitdir: &Path) -> Option<String> {
252 let head = std::fs::read_to_string(gitdir.join("HEAD")).ok()?;
253 head.trim()
254 .strip_prefix("ref: refs/heads/")
255 .map(str::to_string)
256 .filter(|b| !b.is_empty())
257}
258
259pub fn origin_for(path: &Path) -> String {
261 let machine = crate::commands::login::hostname();
262 match git_branch(path) {
263 Some(b) => format!("{machine} \u{b7} {b}"),
264 None => machine,
265 }
266}
267
268#[cfg(test)]
269mod tests {
270 use super::*;
271
272 fn dir() -> tempfile::TempDir {
273 tempfile::tempdir().unwrap()
274 }
275
276 fn put(root: &Path, rel: &str, text: &str) -> PathBuf {
277 let p = root.join(rel);
278 std::fs::create_dir_all(p.parent().unwrap()).unwrap();
279 std::fs::write(&p, text).unwrap();
280 p
281 }
282
283 #[test]
284 fn slugs_follow_the_rule() {
285 let (x40, x41) = ("x".repeat(40), "x".repeat(41));
286 for ok in ["semora", "a", "9-a", x40.as_str()] {
287 assert!(valid_slug(ok), "{ok}");
288 }
289 for bad in ["", "Semora", "-x", "a b", x41.as_str()] {
290 assert!(!valid_slug(bad), "{bad}");
291 }
292 }
293
294 #[test]
295 fn a_slug_is_made_from_a_directory_name() {
296 assert_eq!(slug_from("My Docs_v2").as_deref(), Some("my-docs-v2"));
297 assert_eq!(slug_from(".tmpAbC123").as_deref(), Some("tmpabc123"));
298 assert_eq!(slug_from("--a--b--").as_deref(), Some("a-b"));
299 assert_eq!(slug_from("semora").as_deref(), Some("semora"));
300 assert_eq!(slug_from(&"a".repeat(50)).unwrap().len(), 40);
301 let a39 = "a".repeat(39);
302 assert_eq!(
303 slug_from(&format!("{a39}-b")).as_deref(),
304 Some(a39.as_str()),
305 "no dash left at the cut"
306 );
307 assert_eq!(slug_from("___"), None);
308 assert_eq!(slug_from(""), None);
309 }
310
311 #[test]
312 fn dot_semora_is_parsed_and_checked() {
313 let d = dir();
314 put(
315 d.path(),
316 ".semora",
317 "# note\nserver = \"https://Semora.example.com/\"\nproject = \"semora\"\nextra = 1\n",
318 );
319 let link = read_at(d.path()).unwrap().unwrap();
320 assert_eq!(link.slug, "semora");
321 assert_eq!(
322 link.server, "https://semora.example.com",
323 "the origin, whatever the spelling"
324 );
325
326 for (text, why) in [
327 ("project = \"x\"\n", "server"),
328 ("server = \"https://a.example.com\"\n", "project"),
329 (
330 "server = \"https://a.example.com\"\nproject = \"Bad Id\"\n",
331 "valid project id",
332 ),
333 ("server = \n", "parsing"),
334 ] {
335 put(d.path(), ".semora", text);
336 let err = read_at(d.path()).unwrap_err().to_string();
337 assert!(
338 err.contains(why) && err.contains(".semora"),
339 "{text:?} -> {err}"
340 );
341 }
342 std::fs::remove_file(d.path().join(".semora")).unwrap();
343 assert_eq!(read_at(d.path()).unwrap(), None);
344 }
345
346 #[test]
347 fn find_walks_up_from_the_path_and_the_nearest_file_wins() {
348 let d = dir();
349 put(
350 d.path(),
351 ".semora",
352 "server = \"https://a.example.com\"\nproject = \"outer\"\n",
353 );
354 put(
355 d.path(),
356 "inner/.semora",
357 "server = \"https://a.example.com\"\nproject = \"inner\"\n",
358 );
359 let file = put(d.path(), "docs/deep/x.md", "# x");
360 put(d.path(), "inner/y.md", "# y");
361 assert_eq!(find(&file).unwrap().unwrap().slug, "outer");
362 assert_eq!(
363 find(&d.path().join("docs")).unwrap().unwrap().slug,
364 "outer",
365 "a folder"
366 );
367 assert_eq!(
368 find(&d.path().join("inner/y.md")).unwrap().unwrap().slug,
369 "inner"
370 );
371 assert_eq!(
372 find(&d.path().join("inner")).unwrap().unwrap().slug,
373 "inner",
374 "the folder's own file"
375 );
376 let lonely = tempfile::tempdir().unwrap();
377 let f = put(lonely.path(), "z.md", "# z");
378 assert_eq!(find(&f).unwrap(), None);
379 }
380
381 #[test]
382 fn the_key_path_is_relative_with_forward_slashes() {
383 let d = dir();
384 put(
385 d.path(),
386 ".semora",
387 "server = \"https://a.example.com\"\nproject = \"p\"\n",
388 );
389 let file = put(d.path(), "docs/deep/x.md", "# x");
390 let link = find(&file).unwrap().unwrap();
391 assert_eq!(link.relative_path(&file).unwrap(), "docs/deep/x.md");
392 assert_eq!(link.relative_path(&d.path().join("docs")).unwrap(), "docs");
393 let err = link.relative_path(d.path()).unwrap_err().to_string();
394 assert!(err.contains("not the project root"), "{err}");
395 }
396
397 #[test]
398 fn a_link_for_another_server_names_both() {
399 let d = dir();
400 put(
401 d.path(),
402 ".semora",
403 "server = \"https://a.example.com\"\nproject = \"p\"\n",
404 );
405 let link = read_at(d.path()).unwrap().unwrap();
406 assert!(link.check_origin("https://a.example.com").is_ok());
407 let err = link
408 .check_origin("https://b.example.com")
409 .unwrap_err()
410 .to_string();
411 assert!(
412 err.contains("https://a.example.com") && err.contains("https://b.example.com"),
413 "{err}"
414 );
415 assert!(
416 err.contains("--server") && err.contains("SEMORA_URL"),
417 "says how to use the other server: {err}"
418 );
419 assert!(
420 !err.contains("--id") && !err.contains("--new"),
421 "push flags are push's to mention, not every command's: {err}"
422 );
423 }
424
425 #[test]
426 fn write_makes_a_file_that_reads_back() {
427 let d = dir();
428 let path = write(d.path(), "https://a.example.com", "semora").unwrap();
429 let text = std::fs::read_to_string(&path).unwrap();
430 assert!(text.starts_with("# Semora project."), "{text}");
431 let link = read_at(d.path()).unwrap().unwrap();
432 assert_eq!(
433 (link.server.as_str(), link.slug.as_str()),
434 ("https://a.example.com", "semora")
435 );
436 }
437
438 #[test]
439 fn paths_in_messages_lose_the_windows_verbatim_prefix() {
440 assert_eq!(
441 shown(Path::new(r"\\?\C:\Development\x\.semora")),
442 r"C:\Development\x\.semora"
443 );
444 assert_eq!(shown(Path::new(r"\\?\UNC\nas\share\x")), r"\\nas\share\x");
445 assert_eq!(shown(Path::new("/home/a/x")), "/home/a/x");
446 }
447
448 #[test]
449 fn dots_are_resolved_without_the_file_system() {
450 assert_eq!(lexical(Path::new("/a/b/../c/./d")), PathBuf::from("/a/c/d"));
451 assert_eq!(lexical(Path::new("a/../../b")), PathBuf::from("../b"));
452 }
453
454 #[test]
455 fn the_branch_comes_from_head() {
456 let d = dir();
457 put(d.path(), ".git/HEAD", "ref: refs/heads/feat/projects\n");
458 let file = put(d.path(), "docs/x.md", "# x");
459 assert_eq!(git_branch(&file).as_deref(), Some("feat/projects"));
460 put(
461 d.path(),
462 ".git/HEAD",
463 "0123456789abcdef0123456789abcdef01234567\n",
464 );
465 assert_eq!(git_branch(&file), None, "a detached HEAD has no branch");
466 }
467
468 #[test]
469 fn a_worktree_points_at_its_git_folder_absolute_or_relative() {
470 let d = dir();
471 let main = put(
472 d.path(),
473 "main/.git/worktrees/wt/HEAD",
474 "ref: refs/heads/feat/wt\n",
475 );
476 let abs = main.parent().unwrap().to_string_lossy().replace('\\', "/");
477 put(d.path(), "abs/.git", &format!("gitdir: {abs}\n"));
478 let file = put(d.path(), "abs/x.md", "# x");
479 assert_eq!(
480 git_branch(&file).as_deref(),
481 Some("feat/wt"),
482 "absolute gitdir"
483 );
484 put(d.path(), "rel/.git", "gitdir: ../main/.git/worktrees/wt\n");
486 let file = put(d.path(), "rel/x.md", "# x");
487 assert_eq!(
488 git_branch(&file).as_deref(),
489 Some("feat/wt"),
490 "relative gitdir"
491 );
492 }
493
494 #[test]
495 fn a_verbatim_path_still_reads_a_relative_gitdir() {
496 let d = dir();
499 put(
500 d.path(),
501 "main/.git/worktrees/wt/HEAD",
502 "ref: refs/heads/feat/wt\n",
503 );
504 put(d.path(), "rel/.git", "gitdir: ../main/.git/worktrees/wt\n");
505 let file = put(d.path(), "rel/x.md", "# x");
506 let canonical = std::fs::canonicalize(&file).unwrap();
507 if cfg!(windows) {
508 assert!(
509 canonical.to_string_lossy().starts_with(r"\\?\"),
510 "{canonical:?}"
511 );
512 }
513 assert_eq!(git_branch(&canonical).as_deref(), Some("feat/wt"));
514 }
515
516 #[test]
517 fn origin_is_the_machine_and_the_branch_when_there_is_one() {
518 let d = dir();
519 let file = put(d.path(), "x.md", "# x");
520 let machine = origin_for(&file);
521 assert!(!machine.contains('\u{b7}'), "no branch: {machine}");
522 put(d.path(), ".git/HEAD", "ref: refs/heads/main\n");
523 let with = origin_for(&file);
524 assert_eq!(with, format!("{machine} \u{b7} main"));
525 }
526}