use std::path::{Component, Path, PathBuf};
use anyhow::{Context, bail};
use serde::Deserialize;
use crate::client::Client;
use crate::config::{origin, parse_server};
pub const FILE: &str = ".semora";
pub fn shown(path: &Path) -> String {
let s = path.display().to_string();
match s.strip_prefix(r"\\?\") {
Some(rest) => match rest.strip_prefix(r"UNC\") {
Some(share) => format!(r"\\{share}"),
None => rest.to_string(),
},
None => s,
}
}
#[derive(Debug)]
pub struct OtherServer(pub String);
impl std::fmt::Display for OtherServer {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
impl std::error::Error for OtherServer {}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Link {
pub root: PathBuf,
pub server: String,
pub slug: String,
}
#[derive(Deserialize)]
struct Raw {
server: Option<String>,
project: Option<String>,
}
pub fn valid_slug(s: &str) -> bool {
(1..=40).contains(&s.len())
&& s.bytes()
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
&& !s.starts_with('-')
}
pub fn slug_from(dir_name: &str) -> Option<String> {
let mut out = String::new();
for c in dir_name.to_lowercase().chars() {
if c.is_ascii_lowercase() || c.is_ascii_digit() {
out.push(c);
} else if !out.ends_with('-') {
out.push('-');
}
}
let cut: String = out.trim_matches('-').chars().take(40).collect();
let slug = cut.trim_matches('-').to_string();
valid_slug(&slug).then_some(slug)
}
pub fn read_at(dir: &Path) -> anyhow::Result<Option<Link>> {
let file = dir.join(FILE);
if !file.is_file() {
return Ok(None);
}
let text =
std::fs::read_to_string(&file).with_context(|| format!("reading {}", shown(&file)))?;
let raw: Raw = toml::from_str(&text).with_context(|| format!("parsing {}", shown(&file)))?;
let server = raw
.server
.filter(|s| !s.trim().is_empty())
.with_context(|| format!("{} needs a `server` line", shown(&file)))?;
let slug = raw
.project
.filter(|s| !s.trim().is_empty())
.with_context(|| format!("{} needs a `project` line", shown(&file)))?;
if !valid_slug(&slug) {
bail!(
"{}: `{slug}` is not a valid project id (lowercase letters, digits and dashes, up to 40, starting with a letter or digit)",
shown(&file)
);
}
let server =
parse_server(&server).with_context(|| format!("{}: bad `server`", shown(&file)))?;
Ok(Some(Link {
root: dir.to_path_buf(),
server: origin(&server),
slug,
}))
}
fn start_dir(full: &Path) -> Option<&Path> {
if full.is_dir() {
Some(full)
} else {
full.parent()
}
}
pub fn find(path: &Path) -> anyhow::Result<Option<Link>> {
let full =
std::fs::canonicalize(path).with_context(|| format!("cannot read {}", shown(path)))?;
let mut dir = start_dir(&full);
while let Some(d) = dir {
if let Some(link) = read_at(d)? {
return Ok(Some(link));
}
dir = d.parent();
}
Ok(None)
}
pub fn write(dir: &Path, server: &str, slug: &str) -> anyhow::Result<PathBuf> {
let file = dir.join(FILE);
let text = format!(
"# Semora project. Commit this file so every checkout publishes to the same place.\nserver = \"{server}\"\nproject = \"{slug}\"\n"
);
std::fs::write(&file, text).with_context(|| format!("writing {}", shown(&file)))?;
Ok(file)
}
impl Link {
pub fn check_origin(&self, have: &str) -> anyhow::Result<()> {
if self.server == have {
return Ok(());
}
Err(OtherServer(format!(
"{} is for {}, but this command uses {have}. To use that server, set SEMORA_URL or pass --server {}.",
shown(&self.root.join(FILE)),
self.server,
self.server
))
.into())
}
pub fn check_server(&self, client: &Client) -> anyhow::Result<()> {
self.check_origin(&client.origin())
}
pub fn relative_path(&self, target: &Path) -> anyhow::Result<String> {
let full = std::fs::canonicalize(target)
.with_context(|| format!("cannot read {}", shown(target)))?;
let rel = full.strip_prefix(&self.root).with_context(|| {
format!(
"{} is not inside the project at {}",
shown(&full),
shown(&self.root)
)
})?;
let mut parts = Vec::new();
for c in rel.components() {
let s = c.as_os_str().to_str().with_context(|| {
format!(
"{} isn't valid Unicode, so it can't be a project path",
shown(&full)
)
})?;
parts.push(s.to_string());
}
if parts.is_empty() {
bail!(
"{} is here: push a file or a folder inside this project, not the project root.",
shown(&self.root.join(FILE))
);
}
Ok(parts.join("/"))
}
}
pub fn git_branch(path: &Path) -> Option<String> {
let full = std::path::absolute(path).ok()?;
let mut dir = start_dir(&full);
while let Some(d) = dir {
let dot = d.join(".git");
if dot.is_dir() {
return branch_in(&dot);
}
if dot.is_file() {
let text = std::fs::read_to_string(&dot).ok()?;
let target = text
.lines()
.find_map(|l| l.trim().strip_prefix("gitdir:"))?
.trim();
let gitdir = if Path::new(target).is_absolute() {
PathBuf::from(target)
} else {
lexical(&d.join(target))
};
return branch_in(&gitdir);
}
dir = d.parent();
}
None
}
fn lexical(path: &Path) -> PathBuf {
let mut out = PathBuf::new();
for c in path.components() {
match c {
Component::CurDir => {}
Component::ParentDir => {
if !out.pop() {
out.push("..");
}
}
other => out.push(other.as_os_str()),
}
}
out
}
fn branch_in(gitdir: &Path) -> Option<String> {
let head = std::fs::read_to_string(gitdir.join("HEAD")).ok()?;
head.trim()
.strip_prefix("ref: refs/heads/")
.map(str::to_string)
.filter(|b| !b.is_empty())
}
pub fn origin_for(path: &Path) -> String {
let machine = crate::commands::login::hostname();
match git_branch(path) {
Some(b) => format!("{machine} \u{b7} {b}"),
None => machine,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn dir() -> tempfile::TempDir {
tempfile::tempdir().unwrap()
}
fn put(root: &Path, rel: &str, text: &str) -> PathBuf {
let p = root.join(rel);
std::fs::create_dir_all(p.parent().unwrap()).unwrap();
std::fs::write(&p, text).unwrap();
p
}
#[test]
fn slugs_follow_the_rule() {
let (x40, x41) = ("x".repeat(40), "x".repeat(41));
for ok in ["semora", "a", "9-a", x40.as_str()] {
assert!(valid_slug(ok), "{ok}");
}
for bad in ["", "Semora", "-x", "a b", x41.as_str()] {
assert!(!valid_slug(bad), "{bad}");
}
}
#[test]
fn a_slug_is_made_from_a_directory_name() {
assert_eq!(slug_from("My Docs_v2").as_deref(), Some("my-docs-v2"));
assert_eq!(slug_from(".tmpAbC123").as_deref(), Some("tmpabc123"));
assert_eq!(slug_from("--a--b--").as_deref(), Some("a-b"));
assert_eq!(slug_from("semora").as_deref(), Some("semora"));
assert_eq!(slug_from(&"a".repeat(50)).unwrap().len(), 40);
let a39 = "a".repeat(39);
assert_eq!(
slug_from(&format!("{a39}-b")).as_deref(),
Some(a39.as_str()),
"no dash left at the cut"
);
assert_eq!(slug_from("___"), None);
assert_eq!(slug_from(""), None);
}
#[test]
fn dot_semora_is_parsed_and_checked() {
let d = dir();
put(
d.path(),
".semora",
"# note\nserver = \"https://Semora.example.com/\"\nproject = \"semora\"\nextra = 1\n",
);
let link = read_at(d.path()).unwrap().unwrap();
assert_eq!(link.slug, "semora");
assert_eq!(
link.server, "https://semora.example.com",
"the origin, whatever the spelling"
);
for (text, why) in [
("project = \"x\"\n", "server"),
("server = \"https://a.example.com\"\n", "project"),
(
"server = \"https://a.example.com\"\nproject = \"Bad Id\"\n",
"valid project id",
),
("server = \n", "parsing"),
] {
put(d.path(), ".semora", text);
let err = read_at(d.path()).unwrap_err().to_string();
assert!(
err.contains(why) && err.contains(".semora"),
"{text:?} -> {err}"
);
}
std::fs::remove_file(d.path().join(".semora")).unwrap();
assert_eq!(read_at(d.path()).unwrap(), None);
}
#[test]
fn find_walks_up_from_the_path_and_the_nearest_file_wins() {
let d = dir();
put(
d.path(),
".semora",
"server = \"https://a.example.com\"\nproject = \"outer\"\n",
);
put(
d.path(),
"inner/.semora",
"server = \"https://a.example.com\"\nproject = \"inner\"\n",
);
let file = put(d.path(), "docs/deep/x.md", "# x");
put(d.path(), "inner/y.md", "# y");
assert_eq!(find(&file).unwrap().unwrap().slug, "outer");
assert_eq!(
find(&d.path().join("docs")).unwrap().unwrap().slug,
"outer",
"a folder"
);
assert_eq!(
find(&d.path().join("inner/y.md")).unwrap().unwrap().slug,
"inner"
);
assert_eq!(
find(&d.path().join("inner")).unwrap().unwrap().slug,
"inner",
"the folder's own file"
);
let lonely = tempfile::tempdir().unwrap();
let f = put(lonely.path(), "z.md", "# z");
assert_eq!(find(&f).unwrap(), None);
}
#[test]
fn the_key_path_is_relative_with_forward_slashes() {
let d = dir();
put(
d.path(),
".semora",
"server = \"https://a.example.com\"\nproject = \"p\"\n",
);
let file = put(d.path(), "docs/deep/x.md", "# x");
let link = find(&file).unwrap().unwrap();
assert_eq!(link.relative_path(&file).unwrap(), "docs/deep/x.md");
assert_eq!(link.relative_path(&d.path().join("docs")).unwrap(), "docs");
let err = link.relative_path(d.path()).unwrap_err().to_string();
assert!(err.contains("not the project root"), "{err}");
}
#[test]
fn a_link_for_another_server_names_both() {
let d = dir();
put(
d.path(),
".semora",
"server = \"https://a.example.com\"\nproject = \"p\"\n",
);
let link = read_at(d.path()).unwrap().unwrap();
assert!(link.check_origin("https://a.example.com").is_ok());
let err = link
.check_origin("https://b.example.com")
.unwrap_err()
.to_string();
assert!(
err.contains("https://a.example.com") && err.contains("https://b.example.com"),
"{err}"
);
assert!(
err.contains("--server") && err.contains("SEMORA_URL"),
"says how to use the other server: {err}"
);
assert!(
!err.contains("--id") && !err.contains("--new"),
"push flags are push's to mention, not every command's: {err}"
);
}
#[test]
fn write_makes_a_file_that_reads_back() {
let d = dir();
let path = write(d.path(), "https://a.example.com", "semora").unwrap();
let text = std::fs::read_to_string(&path).unwrap();
assert!(text.starts_with("# Semora project."), "{text}");
let link = read_at(d.path()).unwrap().unwrap();
assert_eq!(
(link.server.as_str(), link.slug.as_str()),
("https://a.example.com", "semora")
);
}
#[test]
fn paths_in_messages_lose_the_windows_verbatim_prefix() {
assert_eq!(
shown(Path::new(r"\\?\C:\Development\x\.semora")),
r"C:\Development\x\.semora"
);
assert_eq!(shown(Path::new(r"\\?\UNC\nas\share\x")), r"\\nas\share\x");
assert_eq!(shown(Path::new("/home/a/x")), "/home/a/x");
}
#[test]
fn dots_are_resolved_without_the_file_system() {
assert_eq!(lexical(Path::new("/a/b/../c/./d")), PathBuf::from("/a/c/d"));
assert_eq!(lexical(Path::new("a/../../b")), PathBuf::from("../b"));
}
#[test]
fn the_branch_comes_from_head() {
let d = dir();
put(d.path(), ".git/HEAD", "ref: refs/heads/feat/projects\n");
let file = put(d.path(), "docs/x.md", "# x");
assert_eq!(git_branch(&file).as_deref(), Some("feat/projects"));
put(
d.path(),
".git/HEAD",
"0123456789abcdef0123456789abcdef01234567\n",
);
assert_eq!(git_branch(&file), None, "a detached HEAD has no branch");
}
#[test]
fn a_worktree_points_at_its_git_folder_absolute_or_relative() {
let d = dir();
let main = put(
d.path(),
"main/.git/worktrees/wt/HEAD",
"ref: refs/heads/feat/wt\n",
);
let abs = main.parent().unwrap().to_string_lossy().replace('\\', "/");
put(d.path(), "abs/.git", &format!("gitdir: {abs}\n"));
let file = put(d.path(), "abs/x.md", "# x");
assert_eq!(
git_branch(&file).as_deref(),
Some("feat/wt"),
"absolute gitdir"
);
put(d.path(), "rel/.git", "gitdir: ../main/.git/worktrees/wt\n");
let file = put(d.path(), "rel/x.md", "# x");
assert_eq!(
git_branch(&file).as_deref(),
Some("feat/wt"),
"relative gitdir"
);
}
#[test]
fn a_verbatim_path_still_reads_a_relative_gitdir() {
let d = dir();
put(
d.path(),
"main/.git/worktrees/wt/HEAD",
"ref: refs/heads/feat/wt\n",
);
put(d.path(), "rel/.git", "gitdir: ../main/.git/worktrees/wt\n");
let file = put(d.path(), "rel/x.md", "# x");
let canonical = std::fs::canonicalize(&file).unwrap();
if cfg!(windows) {
assert!(
canonical.to_string_lossy().starts_with(r"\\?\"),
"{canonical:?}"
);
}
assert_eq!(git_branch(&canonical).as_deref(), Some("feat/wt"));
}
#[test]
fn origin_is_the_machine_and_the_branch_when_there_is_one() {
let d = dir();
let file = put(d.path(), "x.md", "# x");
let machine = origin_for(&file);
assert!(!machine.contains('\u{b7}'), "no branch: {machine}");
put(d.path(), ".git/HEAD", "ref: refs/heads/main\n");
let with = origin_for(&file);
assert_eq!(with, format!("{machine} \u{b7} main"));
}
}