use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use crate::error::{self, Error, Result};
use crate::workspaces::{FileSource, Sourced};
use crate::{git, home, yaml};
pub const VERSION: u32 = 1;
pub const PREFIX_ENV: &str = "ONEVCS_BRANCH_PREFIX";
const PROBE: &str = "branch";
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct BranchesFile {
pub version: u32,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub prefix: String,
}
impl Default for BranchesFile {
fn default() -> Self {
BranchesFile {
version: VERSION,
prefix: String::new(),
}
}
}
pub fn default_path() -> Result<PathBuf> {
Ok(home::root()?.join("branches.yml"))
}
pub(crate) fn load() -> Result<(BranchesFile, FileSource)> {
let path = default_path()?;
if !path.is_file() {
return Ok((BranchesFile::default(), FileSource::Shipped));
}
let raw = std::fs::read_to_string(&path).map_err(|failure| {
error::invalid(format!(
"cannot read the branches file at {}: {failure}",
path.display()
))
})?;
Ok((parse(&path, &raw)?, FileSource::File(path)))
}
pub(crate) fn parse(path: &Path, raw: &str) -> Result<BranchesFile> {
let malformed = |failure: serde_saphyr::Error| {
error::invalid(format!(
"the branches file at {} is malformed: {failure}",
path.display()
))
};
let document: serde_json::Value = yaml::from_str(raw).map_err(malformed)?;
if let Some(declared) = yaml::declared_version(&document) {
if declared < u64::from(VERSION) {
return Err(error::invalid(format!(
"the branches file at {} declares version {declared}; this build reads \
version {VERSION} and newer",
path.display()
)));
}
}
yaml::from_str(raw).map_err(malformed)
}
pub(crate) fn resolve(
file: &BranchesFile,
source: &FileSource,
over: Option<&str>,
) -> Result<Sourced<String>> {
let from_file = Sourced {
value: file.prefix.clone(),
from: match source {
FileSource::File(path) => format!("prefix: of {}", path.display()),
FileSource::Shipped => "the shipped default".to_owned(),
},
};
let standing = match environment_prefix()? {
Some(prefix) => Sourced {
value: prefix,
from: format!("{PREFIX_ENV} in the environment"),
},
None => from_file,
};
let resolved = match over {
Some(prefix) => Sourced {
value: prefix.to_owned(),
from: "--branch-prefix on this open".to_owned(),
},
None => standing,
};
usable(&resolved)?;
Ok(resolved)
}
pub(crate) fn resolve_for(over: Option<&str>) -> Result<Sourced<String>> {
let (file, source) = load()?;
resolve(&file, &source, over)
}
fn environment_prefix() -> Result<Option<String>> {
let Some(raw) = std::env::var_os(PREFIX_ENV) else {
return Ok(None);
};
let Some(text) = raw.to_str() else {
return Err(Error::Invalid {
reason: format!(
"{PREFIX_ENV} is set to bytes that are not text: a branch prefix is the \
namespace every branch of this host is cut under, so it has to be \
something a person can type back. Unset it, or set it to that namespace"
),
});
};
Ok(Some(text.trim().to_owned()))
}
fn usable(prefix: &Sourced<String>) -> Result<()> {
if prefix.value.is_empty() || git::is_valid_branch_name(&format!("{}{PROBE}", prefix.value)) {
return Ok(());
}
Err(Error::Invalid {
reason: format!(
"the branch prefix {value:?} (from {from}) cannot start a branch name git \
accepts: it would cut {spelled:?}, which git refuses",
value = prefix.value,
from = prefix.from,
spelled = format!("{}{PROBE}", prefix.value),
),
})
}
pub(crate) fn sanitize(proposed: &str) -> Option<String> {
let mapped: String = proposed
.chars()
.map(|c| match c {
c if c.is_ascii_alphanumeric() => c,
'-' | '_' | '.' | '/' => c,
_ => '-',
})
.collect();
let components: Vec<String> = mapped.split('/').filter_map(component).collect();
let name = components.join("/");
(!name.is_empty()).then_some(name)
}
fn component(raw: &str) -> Option<String> {
let mut collapsed = String::new();
for c in raw.chars() {
let repeated = matches!(c, '-' | '.') && collapsed.ends_with(c);
if !repeated {
collapsed.push(c);
}
}
let mut trimmed = collapsed.trim_matches(['-', '.']).to_owned();
while let Some(rest) = trimmed.strip_suffix(".lock") {
trimmed = rest.trim_end_matches(['-', '.']).to_owned();
}
(!trimmed.is_empty()).then_some(trimmed)
}