use std::path::{Component, Path, PathBuf};
use anyhow::{Context, bail};
use serde::Deserialize;
use crate::client::Client;
use crate::config::{origin, parse_server};
use crate::local_links::{self, LocalLink};
use crate::{checkout, exit, output, server_id};
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 enum ServerRef {
Id(String),
Url(String),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FileLink {
pub root: PathBuf,
pub server: ServerRef,
pub slug: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Link {
pub root: PathBuf,
pub server: String,
pub slug: String,
pub file_slug: String,
pub local: Option<Mapped>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Mapped {
pub config_dir: PathBuf,
pub record: LocalLink,
}
impl Link {
pub fn mapped(&self) -> bool {
self.local.is_some()
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Fit {
Ours,
LegacyOurs,
LegacyOther(String),
Other,
Mapped(LocalLink),
}
pub fn server_value(server: &ServerRef) -> String {
match server {
ServerRef::Id(id) | ServerRef::Url(id) => id.clone(),
}
}
#[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<FileLink>> {
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 = if server_id::is_id(&server) {
ServerRef::Id(server_id::normal(&server))
} else {
let url =
parse_server(&server).with_context(|| format!("{}: bad `server`", shown(&file)))?;
ServerRef::Url(origin(&url))
};
Ok(Some(FileLink {
root: dir.to_path_buf(),
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<FileLink>> {
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_id: &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_id}\"\nproject = \"{slug}\"\n"
);
std::fs::write(&file, text).with_context(|| format!("writing {}", shown(&file)))?;
Ok(file)
}
pub fn set_line(dir: &Path, key: &str, value: &str) -> anyhow::Result<()> {
let file = dir.join(FILE);
let text =
std::fs::read_to_string(&file).with_context(|| format!("reading {}", shown(&file)))?;
let eol = if text.contains("\r\n") { "\r\n" } else { "\n" };
let mut found = false;
let mut lines: Vec<String> = Vec::new();
for line in text.lines() {
let is_key = line
.trim_start()
.strip_prefix(key)
.is_some_and(|rest| rest.trim_start().starts_with('='));
if is_key && !found {
found = true;
lines.push(format!("{key} = \"{value}\""));
} else {
lines.push(line.to_string());
}
}
if !found {
bail!("{} has no `{key}` line to update", shown(&file));
}
let new_text = lines.join(eol) + eol;
std::fs::write(&file, &new_text).with_context(|| format!("writing {}", shown(&file)))?;
let meant = |l: &FileLink| match key {
"project" => l.slug == value,
_ => l.server == ServerRef::Id(value.to_string()),
};
match read_at(dir) {
Ok(Some(l)) if meant(&l) => Ok(()),
_ => {
std::fs::write(&file, &text).ok();
bail!("could not update the `{key}` line in {}", shown(&file))
}
}
}
pub fn adopt(link: Link, slug: &str) -> Link {
if slug == link.slug || !valid_slug(slug) {
return link;
}
if let Some(m) = &link.local {
let record = LocalLink {
project: slug.to_string(),
..m.record.clone()
};
if let Err(e) = local_links::save(&m.config_dir, record.clone()) {
output::warn(format!(
"project `{}` is now `{slug}` on {}, but this machine's link could not be updated: {e:#}. Run `semora project link --project {slug}`.",
link.slug, link.server
));
return link;
}
let moved = Link {
slug: slug.to_string(),
local: Some(Mapped {
config_dir: m.config_dir.clone(),
record,
}),
..link.clone()
};
if let Err(e) = checkout::move_to(&link, &moved) {
output::warn(format!(
"could not carry this folder's record over to the new id: {e:#}. Run `semora pull` to start it again."
));
}
output::warn(format!(
"project '{}' is now '{slug}' on {}. Updated this machine's link.",
link.slug, link.server
));
return moved;
}
if let Err(e) = set_line(&link.root, "project", slug) {
output::warn(format!(
"project `{}` is now `{slug}` on {}, but {e:#}. Change the `project` line yourself.",
link.slug, link.server
));
return link;
}
let moved = Link {
slug: slug.to_string(),
file_slug: slug.to_string(),
..link.clone()
};
if let Err(e) = checkout::move_to(&link, &moved) {
output::warn(format!(
"could not carry this folder's record over to the new id: {e:#}. Run `semora pull` to start it again."
));
}
output::warn(format!(
"project `{}` is now `{slug}` on {}. Updated {}; commit it so other checkouts follow.",
link.slug,
link.server,
shown(&link.root.join(FILE))
));
moved
}
pub async fn follow(client: &Client, link: Link) -> anyhow::Result<Link> {
let link = match client.project(&link.slug).await {
Ok(info) => adopt(link, &info.slug),
Err(e) if exit::is_not_found(&e) => recover(client, link).await,
Err(_) => link,
};
keep_record(client, &link).await;
Ok(link)
}
async fn keep_record(client: &Client, link: &Link) {
for id in checkout::ids_under_other_id(link)
.into_iter()
.take(RECOVER_TRIES)
{
let Ok(d) = client.doc(&id).await else {
continue;
};
if d.summary.project.as_deref() == Some(link.slug.as_str())
&& let Err(e) = checkout::relabel(link)
{
output::warn(format!(
"could not carry this folder's record over to project `{}`: {e:#}",
link.slug
));
}
return;
}
}
const RECOVER_TRIES: usize = 3;
async fn recover(client: &Client, link: Link) -> Link {
let Ok(records) = checkout::load(&link) else {
return link;
};
let mut ids: Vec<String> = records.into_values().map(|r| r.id).collect();
ids.dedup();
for id in ids.into_iter().take(RECOVER_TRIES) {
if let Ok(d) = client.doc(&id).await
&& let Some(project) = d.summary.project
{
return adopt(link, &project);
}
}
link
}
pub async fn fit(client: &Client, config_dir: &Path, file: &FileLink) -> anyhow::Result<Fit> {
let ours = match &file.server {
ServerRef::Id(id) => {
*id == server_id::of(client, config_dir, false).await?
|| *id == server_id::of(client, config_dir, true).await?
}
ServerRef::Url(u) if *u == client.origin() => return Ok(Fit::LegacyOurs),
ServerRef::Url(_) => false,
};
if ours {
return Ok(Fit::Ours);
}
if let Some(record) = local_links::find(config_dir, &server_value(&file.server), &file.slug)? {
let id = server_id::of(client, config_dir, false).await?;
if record.server_id == id
|| record.server_id == server_id::of(client, config_dir, true).await?
{
return Ok(Fit::Mapped(record));
}
}
Ok(match &file.server {
ServerRef::Url(u) => Fit::LegacyOther(u.clone()),
ServerRef::Id(_) => Fit::Other,
})
}
pub async fn resolve(client: &Client, config_dir: &Path, file: FileLink) -> anyhow::Result<Link> {
let path = shown(&file.root.join(FILE));
let have = client.origin();
let mut local = None;
let mut slug = file.slug.clone();
match fit(client, config_dir, &file).await? {
Fit::Ours => drop_shadowed(client, config_dir, &file, &path).await,
Fit::LegacyOurs => {
let id = server_id::of(client, config_dir, true).await?;
match set_line(&file.root, "server", &id) {
Ok(()) => output::warn(format!(
"{path} now names its server by id instead of address. Commit it. Older commits still hold the address."
)),
Err(e) => output::warn(format!(
"could not update {path}: {e:#}. Change the `server` line to \"{id}\" yourself."
)),
}
}
Fit::Mapped(record) => {
slug = record.project.clone();
local = Some(Mapped {
config_dir: config_dir.to_path_buf(),
record,
});
}
Fit::LegacyOther(theirs) => {
return Err(OtherServer(format!(
"{path} is for {theirs}, but this command uses {have}. To use that server, set SEMORA_URL or pass --server {theirs}."
))
.into());
}
Fit::Other => return Err(OtherServer(other_server_message(&path, &have)).into()),
}
Ok(Link {
root: file.root,
server: have,
slug,
file_slug: file.slug,
local,
})
}
async fn drop_shadowed(client: &Client, config_dir: &Path, file: &FileLink, path: &str) {
let Ok(id) = server_id::of(client, config_dir, false).await else {
return;
};
match local_links::remove_shadowed(config_dir, &id, &file.slug, &file.root) {
Ok(gone) => {
for g in gone.iter().filter(|g| g.project != file.slug) {
output::warn(format!(
"{path} now names this server, so this machine's link to project '{}' is dropped; the file's project '{}' is used.",
g.project, file.slug
));
}
}
Err(e) => output::warn(format!("{e:#}")),
}
}
fn other_server_message(path: &str, have: &str) -> String {
format!(
"{path} is for another Semora server than the one this CLI uses ({have}).
The file does not say which server it is. Either:
- log in to the project's server: semora login --server ADDRESS (ask whoever set up the project)
- use your server for this project on this machine only: semora project link
(your documents drift from everyone else's; nobody else sees them)
- point .semora at your server: semora project link --rewrite
(once you commit it, every other checkout stops working until it does the same)"
)
}
impl Link {
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 linked(f: FileLink) -> Link {
Link {
root: f.root,
server: "https://a.example.com".into(),
slug: f.slug.clone(),
file_slug: f.slug,
local: None,
}
}
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,
ServerRef::Url("https://semora.example.com".into()),
"the origin, whatever the spelling"
);
put(
d.path(),
".semora",
"server = \" 3F9C0E7A5B1D4E2F8A6C9B0D1E2F3A4B \"\nproject = \"semora\"\n",
);
assert_eq!(
read_at(d.path()).unwrap().unwrap().server,
ServerRef::Id("3f9c0e7a5b1d4e2f8a6c9b0d1e2f3a4b".into()),
"an id, normalized"
);
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 = linked(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 the_message_for_another_servers_id_names_only_this_one_and_the_ways_out() {
let m = other_server_message("/r/.semora", "https://mine.example.com");
for part in [
"/r/.semora",
"https://mine.example.com",
"semora login --server ADDRESS",
"semora project link",
"semora project link --rewrite",
] {
assert!(m.contains(part), "{part}: {m}");
}
assert!(
!m.contains("--id") && !m.contains("--new"),
"push flags are push's to mention, not every command's: {m}"
);
}
#[test]
fn write_makes_a_file_that_reads_back() {
let d = dir();
let id = "3f9c0e7a5b1d4e2f8a6c9b0d1e2f3a4b";
let path = write(d.path(), id, "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, link.slug.as_str()),
(ServerRef::Id(id.into()), "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"));
}
}