use std::path::{Path, PathBuf};
use std::time::Duration;
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use crate::error::{self, Error, Result};
use crate::rules::RuleMatch;
use crate::store::{self, Normalized};
use crate::{home, policy};
pub const VERSION: u32 = 1;
pub const DEFAULT_MAINTAIN_TIMEOUT: &str = "30m";
pub const POOL_ENV: &str = "ONEVCS_POOL";
pub const OVERFLOW_ENV: &str = "ONEVCS_OVERFLOW";
#[derive(Clone, Copy, PartialEq, Eq, Hash)]
pub struct Span {
value: u64,
unit: Unit,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
enum Unit {
Seconds,
Minutes,
Hours,
Days,
}
impl Unit {
fn letter(self) -> char {
match self {
Unit::Seconds => 's',
Unit::Minutes => 'm',
Unit::Hours => 'h',
Unit::Days => 'd',
}
}
fn seconds(self) -> u64 {
match self {
Unit::Seconds => 1,
Unit::Minutes => 60,
Unit::Hours => 3_600,
Unit::Days => 86_400,
}
}
}
const SPAN_GRAMMAR: &str =
"a span is one or more digits followed by exactly one unit letter — s, m, h or d — \
naming a positive duration, such as 7d, 36h, 90m or 600s";
impl Span {
pub fn as_duration(&self) -> Duration {
Duration::from_secs(self.value.saturating_mul(self.unit.seconds()))
}
}
impl std::str::FromStr for Span {
type Err = String;
fn from_str(text: &str) -> std::result::Result<Self, Self::Err> {
let refuse = |what: &str| Err(format!("{text:?} is not a span: {what}; {SPAN_GRAMMAR}"));
if text.is_empty() {
return refuse("it is empty");
}
if text.chars().any(char::is_whitespace) {
return refuse("it contains a space");
}
let Some(unit) = text.chars().last() else {
return refuse("it is empty");
};
let unit = match unit {
's' => Unit::Seconds,
'm' => Unit::Minutes,
'h' => Unit::Hours,
'd' => Unit::Days,
other if other.is_ascii_digit() => return refuse("it names no unit"),
other => return refuse(&format!("{other:?} is not a unit letter")),
};
let digits = &text[..text.len() - 1];
if digits.is_empty() {
return refuse("it has no number before the unit");
}
if digits.starts_with('+') || digits.starts_with('-') {
return refuse("a span is not signed");
}
if digits.contains('.') {
return refuse("a span is a whole number");
}
if !digits.chars().all(|c| c.is_ascii_digit()) {
return refuse("it names more than one unit, or something that is not a number");
}
let value: u64 = match digits.parse() {
Ok(value) => value,
Err(_) => return refuse("the number is too large"),
};
if value == 0 {
return refuse("a span of zero names no duration");
}
if value.checked_mul(unit.seconds()).is_none() {
return refuse("the number is too large");
}
Ok(Span { value, unit })
}
}
impl std::fmt::Display for Span {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}{}", self.value, self.unit.letter())
}
}
impl std::fmt::Debug for Span {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{:?}", self.to_string())
}
}
impl Serialize for Span {
fn serialize<S: Serializer>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error> {
serializer.serialize_str(&self.to_string())
}
}
impl<'de> Deserialize<'de> for Span {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> std::result::Result<Self, D::Error> {
let text = String::deserialize(deserializer)?;
text.parse().map_err(serde::de::Error::custom)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Bound {
Unlimited,
Bounded(u32),
}
impl Bound {
pub fn as_string(&self) -> String {
match self {
Bound::Unlimited => "unlimited".to_owned(),
Bound::Bounded(count) => count.to_string(),
}
}
pub fn admits(&self, used: u32) -> bool {
match self {
Bound::Unlimited => true,
Bound::Bounded(count) => used < *count,
}
}
pub fn headroom(&self, used: u32) -> Bound {
match self {
Bound::Unlimited => Bound::Unlimited,
Bound::Bounded(count) => Bound::Bounded(count.saturating_sub(used)),
}
}
}
impl std::str::FromStr for Bound {
type Err = String;
fn from_str(text: &str) -> std::result::Result<Self, Self::Err> {
if text == "unlimited" {
return Ok(Bound::Unlimited);
}
if !text.is_empty() && text.chars().all(|c| c.is_ascii_digit()) {
if let Ok(count) = text.parse() {
return Ok(Bound::Bounded(count));
}
}
Err(format!(
"{text:?} is not a bound: write `unlimited` or a non-negative integer"
))
}
}
impl std::fmt::Display for Bound {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.as_string())
}
}
impl Serialize for Bound {
fn serialize<S: Serializer>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error> {
match self {
Bound::Unlimited => serializer.serialize_str("unlimited"),
Bound::Bounded(count) => serializer.serialize_u32(*count),
}
}
}
#[derive(Deserialize)]
#[serde(untagged)]
enum RawBound {
Number(u64),
Text(String),
}
impl<'de> Deserialize<'de> for Bound {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> std::result::Result<Self, D::Error> {
match RawBound::deserialize(deserializer)? {
RawBound::Number(count) => u32::try_from(count)
.map(Bound::Bounded)
.map_err(|_| serde::de::Error::custom(format!("{count} is too large a bound"))),
RawBound::Text(text) => text.parse().map_err(serde::de::Error::custom),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct WorkspacesFile {
pub version: u32,
#[serde(default)]
pub default: WorkspaceDefault,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub rules: Vec<WorkspaceRule>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct WorkspaceDefault {
#[serde(default)]
pub pool: u32,
#[serde(default = "unlimited")]
pub overflow: Bound,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub delete: Vec<PathBuf>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub maintain: Option<Maintenance>,
}
fn unlimited() -> Bound {
Bound::Unlimited
}
impl Default for WorkspaceDefault {
fn default() -> Self {
WorkspaceDefault {
pool: 0,
overflow: Bound::Unlimited,
delete: Vec::new(),
maintain: None,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct WorkspaceRule {
#[serde(rename = "match")]
pub r#match: RuleMatch,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pool: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub overflow: Option<Bound>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub delete: Option<Vec<PathBuf>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub maintain: Option<Maintenance>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Maintenance {
pub command: Vec<String>,
#[serde(default = "default_timeout")]
pub timeout: Span,
}
fn default_timeout() -> Span {
DEFAULT_MAINTAIN_TIMEOUT
.parse()
.expect("the shipped maintenance bound is a span")
}
pub fn default_path() -> Result<PathBuf> {
Ok(home::root()?.join("workspaces.yml"))
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum FileSource {
File(PathBuf),
Shipped,
}
impl std::fmt::Display for FileSource {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
FileSource::File(path) => write!(f, "{}", path.display()),
FileSource::Shipped => f.write_str("the shipped default"),
}
}
}
pub(crate) fn load() -> Result<(WorkspacesFile, FileSource)> {
let path = default_path()?;
if !path.is_file() {
return Ok((
WorkspacesFile {
version: VERSION,
default: WorkspaceDefault::default(),
rules: Vec::new(),
},
FileSource::Shipped,
));
}
let raw = std::fs::read_to_string(&path).map_err(|failure| {
error::invalid(format!(
"cannot read the workspaces file at {}: {failure}",
path.display()
))
})?;
let malformed = |failure: serde_yaml_ng::Error| {
error::invalid(format!(
"the workspaces file at {} is malformed: {failure}",
path.display()
))
};
let document: serde_yaml_ng::Value = serde_yaml_ng::from_str(&raw).map_err(malformed)?;
if let Some(declared) = document
.get("version")
.and_then(serde_yaml_ng::Value::as_u64)
{
if declared < u64::from(VERSION) {
return Err(error::invalid(format!(
"the workspaces file at {} declares version {declared}; this build reads \
version {VERSION} and newer",
path.display()
)));
}
}
let file: WorkspacesFile = serde_yaml_ng::from_value(document).map_err(malformed)?;
validate(&path, &file)?;
Ok((file, FileSource::File(path)))
}
fn validate(path: &Path, file: &WorkspacesFile) -> Result<()> {
let mut checked: Vec<(String, &WorkspaceDefault)> = vec![("default".to_owned(), &file.default)];
let resolved: Vec<(String, WorkspaceDefault)> = file
.rules
.iter()
.enumerate()
.map(|(index, rule)| {
(
format!("rule {}", index + 1),
WorkspaceDefault {
pool: rule.pool.unwrap_or(file.default.pool),
overflow: rule.overflow.unwrap_or(file.default.overflow),
delete: rule.delete.clone().unwrap_or_default(),
maintain: rule.maintain.clone(),
},
)
})
.collect();
checked.extend(resolved.iter().map(|(name, value)| (name.clone(), value)));
for (where_, policy) in checked {
for entry in &policy.delete {
if let Some(reason) = escapes(entry) {
return Err(error::invalid(format!(
"the workspaces file at {} names {entry:?} under delete in {where_}, which \
{reason}; a delete entry is a relative path inside the worktree",
path.display()
)));
}
}
if let Some(maintain) = &policy.maintain {
if maintain.command.is_empty() {
return Err(error::invalid(format!(
"the workspaces file at {} has {where_} naming a maintain command with no \
argv; name the program to run and its arguments",
path.display()
)));
}
}
if policy.pool == 0 && policy.overflow == Bound::Bounded(0) {
return Err(error::invalid(format!(
"the workspaces file at {} has {where_} combining pool: 0 with overflow: 0, \
which admits no session at all: raise one of them",
path.display()
)));
}
}
Ok(())
}
fn escapes(entry: &Path) -> Option<&'static str> {
if entry.as_os_str().is_empty() {
return Some("is empty");
}
if entry.is_absolute() || entry.has_root() {
return Some("is an absolute path");
}
if entry.components().any(|component| {
matches!(
component,
std::path::Component::ParentDir | std::path::Component::Prefix(_)
)
}) {
return Some("climbs out of the worktree");
}
None
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct Sourced<T> {
pub value: T,
pub from: String,
}
#[derive(Debug, Clone, Copy, Default)]
pub(crate) struct Overrides {
pub pool: Option<u32>,
pub overflow: Option<Bound>,
}
#[derive(Debug, Clone)]
pub(crate) struct Resolved {
pub pool: Sourced<u32>,
pub overflow: Sourced<Bound>,
pub file_pool: u32,
pub delete: Vec<PathBuf>,
pub maintain: Option<Maintenance>,
pub configured: bool,
}
pub(crate) fn resolve(
file: &WorkspacesFile,
source: &FileSource,
identity: &Normalized,
checkout: &Path,
overrides: Overrides,
) -> Result<Resolved> {
let matched = file
.rules
.iter()
.enumerate()
.find(|(_, rule)| policy::matches(&rule.r#match, identity, checkout));
let layer = |named: &str| match source {
FileSource::File(path) => format!("{named} of {}", path.display()),
FileSource::Shipped => "the shipped default".to_owned(),
};
let (rule_name, rule) = match matched {
Some((index, rule)) => (Some(format!("rule {}", index + 1)), Some(rule)),
None => (None, None),
};
let from_file_pool = match (rule.and_then(|rule| rule.pool), &rule_name) {
(Some(pool), Some(named)) => Sourced {
value: pool,
from: layer(named),
},
_ => Sourced {
value: file.default.pool,
from: layer("default:"),
},
};
let from_file_overflow = match (rule.and_then(|rule| rule.overflow), &rule_name) {
(Some(overflow), Some(named)) => Sourced {
value: overflow,
from: layer(named),
},
_ => Sourced {
value: file.default.overflow,
from: layer("default:"),
},
};
let file_pool = from_file_pool.value;
let standing_pool = match environment_pool()? {
Some(pool) => Sourced {
value: pool,
from: format!("{POOL_ENV} in the environment"),
},
None => from_file_pool,
};
let standing_overflow = match environment_overflow()? {
Some(overflow) => Sourced {
value: overflow,
from: format!("{OVERFLOW_ENV} in the environment"),
},
None => from_file_overflow,
};
let pool = match overrides.pool {
Some(pool) => Sourced {
value: pool,
from: "--pool on this open".to_owned(),
},
None => standing_pool,
};
let overflow = match overrides.overflow {
Some(overflow) => Sourced {
value: overflow,
from: "--overflow on this open".to_owned(),
},
None => standing_overflow,
};
if pool.value == 0 && overflow.value == Bound::Bounded(0) {
return Err(error::invalid(format!(
"no session of {} can be admitted: pool is 0 (from {}) and overflow is 0 (from {}), \
which together admit nothing; raise one of them",
identity.key, pool.from, overflow.from
)));
}
let delete = rule
.and_then(|rule| rule.delete.clone())
.unwrap_or_else(|| file.default.delete.clone());
let maintain = rule
.and_then(|rule| rule.maintain.clone())
.or_else(|| file.default.maintain.clone());
let configured = pool.value > 0
|| overflow.value != Bound::Unlimited
|| file_pool > 0
|| !delete.is_empty()
|| maintain.is_some();
Ok(Resolved {
pool,
overflow,
file_pool,
delete,
maintain,
configured,
})
}
pub(crate) fn resolve_for(
resolution: &store::Resolution,
overrides: Overrides,
) -> Result<Resolved> {
let (file, source) = load()?;
let normalized = store::normalize(&resolution.identity.origin);
resolve(
&file,
&source,
&normalized,
&resolution.publication,
overrides,
)
}
fn environment_pool() -> Result<Option<u32>> {
let Some(raw) = std::env::var_os(POOL_ENV) else {
return Ok(None);
};
let raw = raw.to_string_lossy().into_owned();
let trimmed = raw.trim();
if !trimmed.is_empty() && trimmed.chars().all(|c| c.is_ascii_digit()) {
if let Ok(pool) = trimmed.parse() {
return Ok(Some(pool));
}
}
Err(Error::Invalid {
reason: format!("{POOL_ENV} must be a non-negative integer, not {raw:?}"),
})
}
fn environment_overflow() -> Result<Option<Bound>> {
let Some(raw) = std::env::var_os(OVERFLOW_ENV) else {
return Ok(None);
};
let raw = raw.to_string_lossy().into_owned();
raw.trim()
.parse()
.map(Some)
.map_err(|reason: String| Error::Invalid {
reason: format!("{OVERFLOW_ENV} is not a bound: {reason}"),
})
}
pub fn first_matching(criteria: &[RuleMatch], repo: &str) -> Result<Option<usize>> {
let registry = store::load()?;
let resolution = store::resolve(®istry, repo)?;
let normalized = store::normalize(&resolution.identity.origin);
Ok(criteria
.iter()
.position(|rule| policy::matches(rule, &normalized, &resolution.publication)))
}