1use std::path::{Path, PathBuf};
21use std::time::Duration;
22
23use serde::ser::SerializeSeq;
24use serde::{Deserialize, Deserializer, Serialize, Serializer};
25
26use crate::error::{self, Error, Result};
27use crate::rules::RuleMatch;
28use crate::store::{self, Normalized};
29use crate::{home, policy};
30
31pub const VERSION: u32 = 1;
38
39pub const DEFAULT_MAINTAIN_TIMEOUT: &str = "30m";
41
42pub const POOL_ENV: &str = "ONEVCS_POOL";
44
45pub const OVERFLOW_ENV: &str = "ONEVCS_OVERFLOW";
47
48#[derive(Clone, Copy, PartialEq, Eq, Hash)]
58pub struct Span {
59 value: u64,
60 unit: Unit,
61}
62
63#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
64enum Unit {
65 Seconds,
66 Minutes,
67 Hours,
68 Days,
69}
70
71impl Unit {
72 fn letter(self) -> char {
73 match self {
74 Unit::Seconds => 's',
75 Unit::Minutes => 'm',
76 Unit::Hours => 'h',
77 Unit::Days => 'd',
78 }
79 }
80
81 fn seconds(self) -> u64 {
82 match self {
83 Unit::Seconds => 1,
84 Unit::Minutes => 60,
85 Unit::Hours => 3_600,
86 Unit::Days => 86_400,
87 }
88 }
89}
90
91const SPAN_GRAMMAR: &str =
94 "a span is one or more digits followed by exactly one unit letter — s, m, h or d — \
95 naming a positive duration, such as 7d, 36h, 90m or 600s";
96
97impl Span {
98 pub fn as_duration(&self) -> Duration {
100 Duration::from_secs(self.value.saturating_mul(self.unit.seconds()))
101 }
102}
103
104impl std::str::FromStr for Span {
105 type Err = String;
106
107 fn from_str(text: &str) -> std::result::Result<Self, Self::Err> {
108 let refuse = |what: &str| Err(format!("{text:?} is not a span: {what}; {SPAN_GRAMMAR}"));
109 if text.is_empty() {
110 return refuse("it is empty");
111 }
112 if text.chars().any(char::is_whitespace) {
113 return refuse("it contains a space");
114 }
115 let Some(unit) = text.chars().last() else {
116 return refuse("it is empty");
117 };
118 let unit = match unit {
119 's' => Unit::Seconds,
120 'm' => Unit::Minutes,
121 'h' => Unit::Hours,
122 'd' => Unit::Days,
123 other if other.is_ascii_digit() => return refuse("it names no unit"),
124 other => return refuse(&format!("{other:?} is not a unit letter")),
125 };
126 let digits = &text[..text.len() - 1];
127 if digits.is_empty() {
128 return refuse("it has no number before the unit");
129 }
130 if digits.starts_with('+') || digits.starts_with('-') {
131 return refuse("a span is not signed");
132 }
133 if digits.contains('.') {
134 return refuse("a span is a whole number");
135 }
136 if !digits.chars().all(|c| c.is_ascii_digit()) {
137 return refuse("it names more than one unit, or something that is not a number");
138 }
139 let value: u64 = match digits.parse() {
140 Ok(value) => value,
141 Err(_) => return refuse("the number is too large"),
142 };
143 if value == 0 {
144 return refuse("a span of zero names no duration");
145 }
146 if value.checked_mul(unit.seconds()).is_none() {
147 return refuse("the number is too large");
148 }
149 Ok(Span { value, unit })
150 }
151}
152
153impl std::fmt::Display for Span {
155 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
156 write!(f, "{}{}", self.value, self.unit.letter())
157 }
158}
159
160impl std::fmt::Debug for Span {
163 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
164 write!(f, "{:?}", self.to_string())
165 }
166}
167
168impl Serialize for Span {
169 fn serialize<S: Serializer>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error> {
170 serializer.serialize_str(&self.to_string())
171 }
172}
173
174impl<'de> Deserialize<'de> for Span {
175 fn deserialize<D: Deserializer<'de>>(deserializer: D) -> std::result::Result<Self, D::Error> {
176 let text = String::deserialize(deserializer)?;
177 text.parse().map_err(serde::de::Error::custom)
178 }
179}
180
181#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
188pub enum Bound {
189 Unlimited,
191 Bounded(u32),
193}
194
195impl Bound {
196 pub fn as_string(&self) -> String {
198 match self {
199 Bound::Unlimited => "unlimited".to_owned(),
200 Bound::Bounded(count) => count.to_string(),
201 }
202 }
203
204 pub fn admits(&self, used: u32) -> bool {
206 match self {
207 Bound::Unlimited => true,
208 Bound::Bounded(count) => used < *count,
209 }
210 }
211
212 pub fn headroom(&self, used: u32) -> Bound {
214 match self {
215 Bound::Unlimited => Bound::Unlimited,
216 Bound::Bounded(count) => Bound::Bounded(count.saturating_sub(used)),
217 }
218 }
219}
220
221impl std::str::FromStr for Bound {
222 type Err = String;
223
224 fn from_str(text: &str) -> std::result::Result<Self, Self::Err> {
225 if text == "unlimited" {
226 return Ok(Bound::Unlimited);
227 }
228 if !text.is_empty() && text.chars().all(|c| c.is_ascii_digit()) {
229 if let Ok(count) = text.parse() {
230 return Ok(Bound::Bounded(count));
231 }
232 }
233 Err(format!(
234 "{text:?} is not a bound: write `unlimited` or a non-negative integer"
235 ))
236 }
237}
238
239impl std::fmt::Display for Bound {
240 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
241 f.write_str(&self.as_string())
242 }
243}
244
245impl Serialize for Bound {
246 fn serialize<S: Serializer>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error> {
247 match self {
248 Bound::Unlimited => serializer.serialize_str("unlimited"),
249 Bound::Bounded(count) => serializer.serialize_u32(*count),
250 }
251 }
252}
253
254#[derive(Deserialize)]
256#[serde(untagged)]
257enum RawBound {
258 Number(u64),
259 Text(String),
260}
261
262impl<'de> Deserialize<'de> for Bound {
263 fn deserialize<D: Deserializer<'de>>(deserializer: D) -> std::result::Result<Self, D::Error> {
264 match RawBound::deserialize(deserializer)? {
265 RawBound::Number(count) => u32::try_from(count)
266 .map(Bound::Bounded)
267 .map_err(|_| serde::de::Error::custom(format!("{count} is too large a bound"))),
268 RawBound::Text(text) => text.parse().map_err(serde::de::Error::custom),
269 }
270 }
271}
272
273#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
275pub struct WorkspacesFile {
276 pub version: u32,
284 #[serde(default)]
287 pub default: WorkspaceDefault,
288 #[serde(default, skip_serializing_if = "Vec::is_empty")]
290 pub rules: Vec<WorkspaceRule>,
291}
292
293#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
295pub struct WorkspaceDefault {
296 #[serde(default)]
299 pub pool: u32,
300 #[serde(default = "unlimited")]
303 pub overflow: Bound,
304 #[serde(default, skip_serializing_if = "Vec::is_empty")]
307 pub delete: Vec<PathBuf>,
308 #[serde(default, skip_serializing_if = "Option::is_none")]
311 pub maintain: Option<Maintenance>,
312}
313
314fn unlimited() -> Bound {
315 Bound::Unlimited
316}
317
318impl Default for WorkspaceDefault {
319 fn default() -> Self {
321 WorkspaceDefault {
322 pool: 0,
323 overflow: Bound::Unlimited,
324 delete: Vec::new(),
325 maintain: None,
326 }
327 }
328}
329
330#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
332pub struct WorkspaceRule {
333 #[serde(rename = "match")]
335 pub r#match: RuleMatch,
336 #[serde(default, skip_serializing_if = "Option::is_none")]
338 pub pool: Option<u32>,
339 #[serde(default, skip_serializing_if = "Option::is_none")]
341 pub overflow: Option<Bound>,
342 #[serde(default, skip_serializing_if = "Option::is_none")]
344 pub delete: Option<Vec<PathBuf>>,
345 #[serde(default, skip_serializing_if = "Option::is_none")]
347 pub maintain: Option<Maintenance>,
348}
349
350#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
352pub struct Maintenance {
353 pub command: MaintenanceCommand,
355 #[serde(default = "default_timeout")]
357 pub timeout: Span,
358}
359
360#[derive(Debug, Clone, PartialEq, Eq)]
376pub struct MaintenanceCommand {
377 pub program: String,
389 pub args: Vec<String>,
394}
395
396const EMPTY_COMMAND: &str =
398 "maintain.command is an empty list: name the program to run and its arguments";
399
400const EMPTY_PROGRAM: &str =
404 "maintain.command names an empty program: its first element is the program to run";
405
406impl MaintenanceCommand {
407 pub fn argv(&self) -> Vec<&str> {
410 std::iter::once(self.program.as_str())
411 .chain(self.args.iter().map(String::as_str))
412 .collect()
413 }
414}
415
416impl Serialize for MaintenanceCommand {
418 fn serialize<S: Serializer>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error> {
419 let mut sequence = serializer.serialize_seq(Some(self.args.len() + 1))?;
420 for part in self.argv() {
421 sequence.serialize_element(part)?;
422 }
423 sequence.end()
424 }
425}
426
427impl<'de> Deserialize<'de> for MaintenanceCommand {
428 fn deserialize<D: Deserializer<'de>>(deserializer: D) -> std::result::Result<Self, D::Error> {
429 let argv = Vec::<String>::deserialize(deserializer)?;
430 let Some((program, args)) = argv.split_first() else {
431 return Err(serde::de::Error::custom(EMPTY_COMMAND));
432 };
433 if program.is_empty() {
434 return Err(serde::de::Error::custom(EMPTY_PROGRAM));
435 }
436 Ok(MaintenanceCommand {
437 program: program.clone(),
438 args: args.to_vec(),
439 })
440 }
441}
442
443fn default_timeout() -> Span {
444 DEFAULT_MAINTAIN_TIMEOUT
445 .parse()
446 .expect("the shipped maintenance bound is a span")
447}
448
449pub fn default_path() -> Result<PathBuf> {
453 Ok(home::root()?.join("workspaces.yml"))
454}
455
456#[derive(Debug, Clone, PartialEq, Eq)]
462pub(crate) enum FileSource {
463 File(PathBuf),
465 Shipped,
467}
468
469impl std::fmt::Display for FileSource {
470 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
471 match self {
472 FileSource::File(path) => write!(f, "{}", path.display()),
473 FileSource::Shipped => f.write_str("the shipped default"),
474 }
475 }
476}
477
478pub(crate) fn load() -> Result<(WorkspacesFile, FileSource)> {
483 let path = default_path()?;
484 if !path.is_file() {
485 return Ok((
486 WorkspacesFile {
487 version: VERSION,
488 default: WorkspaceDefault::default(),
489 rules: Vec::new(),
490 },
491 FileSource::Shipped,
492 ));
493 }
494 let raw = std::fs::read_to_string(&path).map_err(|failure| {
495 error::invalid(format!(
496 "cannot read the workspaces file at {}: {failure}",
497 path.display()
498 ))
499 })?;
500 let malformed = |failure: serde_yaml_ng::Error| {
501 error::invalid(format!(
502 "the workspaces file at {} is malformed: {failure}",
503 path.display()
504 ))
505 };
506 let document: serde_yaml_ng::Value = serde_yaml_ng::from_str(&raw).map_err(malformed)?;
507 if let Some(declared) = document
511 .get("version")
512 .and_then(serde_yaml_ng::Value::as_u64)
513 {
514 if declared < u64::from(VERSION) {
515 return Err(error::invalid(format!(
516 "the workspaces file at {} declares version {declared}; this build reads \
517 version {VERSION} and newer",
518 path.display()
519 )));
520 }
521 }
522 let file: WorkspacesFile = serde_yaml_ng::from_value(document).map_err(malformed)?;
523 validate(&path, &file)?;
524 Ok((file, FileSource::File(path)))
525}
526
527fn validate(path: &Path, file: &WorkspacesFile) -> Result<()> {
538 let mut checked: Vec<(String, &WorkspaceDefault)> = vec![("default".to_owned(), &file.default)];
539 let resolved: Vec<(String, WorkspaceDefault)> = file
540 .rules
541 .iter()
542 .enumerate()
543 .map(|(index, rule)| {
544 (
545 format!("rule {}", index + 1),
546 WorkspaceDefault {
547 pool: rule.pool.unwrap_or(file.default.pool),
548 overflow: rule.overflow.unwrap_or(file.default.overflow),
549 delete: rule.delete.clone().unwrap_or_default(),
550 maintain: rule.maintain.clone(),
551 },
552 )
553 })
554 .collect();
555 checked.extend(resolved.iter().map(|(name, value)| (name.clone(), value)));
556 for (where_, policy) in checked {
557 for entry in &policy.delete {
558 if let Some(reason) = escapes(entry) {
559 return Err(error::invalid(format!(
560 "the workspaces file at {} names {entry:?} under delete in {where_}, which \
561 {reason}; a delete entry is a relative path inside the worktree",
562 path.display()
563 )));
564 }
565 }
566 if policy.pool == 0 && policy.overflow == Bound::Bounded(0) {
567 return Err(error::invalid(format!(
568 "the workspaces file at {} has {where_} combining pool: 0 with overflow: 0, \
569 which admits no session at all: raise one of them",
570 path.display()
571 )));
572 }
573 }
574 Ok(())
575}
576
577fn escapes(entry: &Path) -> Option<&'static str> {
579 if entry.as_os_str().is_empty() {
580 return Some("is empty");
581 }
582 if entry.is_absolute() || entry.has_root() {
583 return Some("is an absolute path");
584 }
585 if entry.components().any(|component| {
586 matches!(
587 component,
588 std::path::Component::ParentDir | std::path::Component::Prefix(_)
589 )
590 }) {
591 return Some("climbs out of the worktree");
592 }
593 None
594}
595
596#[derive(Debug, Clone, PartialEq, Eq)]
598pub(crate) struct Sourced<T> {
599 pub value: T,
601 pub from: String,
603}
604
605#[derive(Debug, Clone, Copy, Default)]
607pub(crate) struct Overrides {
608 pub pool: Option<u32>,
609 pub overflow: Option<Bound>,
610}
611
612#[derive(Debug, Clone)]
614pub(crate) struct Resolved {
615 pub pool: Sourced<u32>,
617 pub overflow: Sourced<Bound>,
619 pub file_pool: u32,
625 pub delete: Vec<PathBuf>,
627 pub maintain: Option<Maintenance>,
630 pub configured: bool,
634}
635
636pub(crate) fn resolve(
644 file: &WorkspacesFile,
645 source: &FileSource,
646 identity: &Normalized,
647 checkout: &Path,
648 overrides: Overrides,
649) -> Result<Resolved> {
650 let matched = file
651 .rules
652 .iter()
653 .enumerate()
654 .find(|(_, rule)| policy::matches(&rule.r#match, identity, checkout));
655 let layer = |named: &str| match source {
656 FileSource::File(path) => format!("{named} of {}", path.display()),
657 FileSource::Shipped => "the shipped default".to_owned(),
658 };
659 let (rule_name, rule) = match matched {
660 Some((index, rule)) => (Some(format!("rule {}", index + 1)), Some(rule)),
661 None => (None, None),
662 };
663 let from_file_pool = match (rule.and_then(|rule| rule.pool), &rule_name) {
664 (Some(pool), Some(named)) => Sourced {
665 value: pool,
666 from: layer(named),
667 },
668 _ => Sourced {
669 value: file.default.pool,
670 from: layer("default:"),
671 },
672 };
673 let from_file_overflow = match (rule.and_then(|rule| rule.overflow), &rule_name) {
674 (Some(overflow), Some(named)) => Sourced {
675 value: overflow,
676 from: layer(named),
677 },
678 _ => Sourced {
679 value: file.default.overflow,
680 from: layer("default:"),
681 },
682 };
683 let file_pool = from_file_pool.value;
684 let standing_pool = match environment_pool()? {
685 Some(pool) => Sourced {
686 value: pool,
687 from: format!("{POOL_ENV} in the environment"),
688 },
689 None => from_file_pool,
690 };
691 let standing_overflow = match environment_overflow()? {
692 Some(overflow) => Sourced {
693 value: overflow,
694 from: format!("{OVERFLOW_ENV} in the environment"),
695 },
696 None => from_file_overflow,
697 };
698 let pool = match overrides.pool {
699 Some(pool) => Sourced {
700 value: pool,
701 from: "--pool on this open".to_owned(),
702 },
703 None => standing_pool,
704 };
705 let overflow = match overrides.overflow {
706 Some(overflow) => Sourced {
707 value: overflow,
708 from: "--overflow on this open".to_owned(),
709 },
710 None => standing_overflow,
711 };
712 if pool.value == 0 && overflow.value == Bound::Bounded(0) {
713 return Err(error::invalid(format!(
714 "no session of {} can be admitted: pool is 0 (from {}) and overflow is 0 (from {}), \
715 which together admit nothing; raise one of them",
716 identity.key, pool.from, overflow.from
717 )));
718 }
719 let delete = rule
720 .and_then(|rule| rule.delete.clone())
721 .unwrap_or_else(|| file.default.delete.clone());
722 let maintain = rule
723 .and_then(|rule| rule.maintain.clone())
724 .or_else(|| file.default.maintain.clone());
725 let configured = pool.value > 0
726 || overflow.value != Bound::Unlimited
727 || file_pool > 0
728 || !delete.is_empty()
729 || maintain.is_some();
730 Ok(Resolved {
731 pool,
732 overflow,
733 file_pool,
734 delete,
735 maintain,
736 configured,
737 })
738}
739
740pub(crate) fn resolve_for(
742 resolution: &store::Resolution,
743 overrides: Overrides,
744) -> Result<Resolved> {
745 let (file, source) = load()?;
746 let normalized = store::normalize(&resolution.identity.origin);
747 resolve(
748 &file,
749 &source,
750 &normalized,
751 &resolution.publication,
752 overrides,
753 )
754}
755
756fn environment_pool() -> Result<Option<u32>> {
758 let Some(raw) = std::env::var_os(POOL_ENV) else {
759 return Ok(None);
760 };
761 let raw = raw.to_string_lossy().into_owned();
762 let trimmed = raw.trim();
763 if !trimmed.is_empty() && trimmed.chars().all(|c| c.is_ascii_digit()) {
764 if let Ok(pool) = trimmed.parse() {
765 return Ok(Some(pool));
766 }
767 }
768 Err(Error::Invalid {
769 reason: format!("{POOL_ENV} must be a non-negative integer, not {raw:?}"),
770 })
771}
772
773fn environment_overflow() -> Result<Option<Bound>> {
776 let Some(raw) = std::env::var_os(OVERFLOW_ENV) else {
777 return Ok(None);
778 };
779 let raw = raw.to_string_lossy().into_owned();
780 raw.trim()
781 .parse()
782 .map(Some)
783 .map_err(|reason: String| Error::Invalid {
784 reason: format!("{OVERFLOW_ENV} is not a bound: {reason}"),
785 })
786}
787
788pub fn first_matching(criteria: &[RuleMatch], repo: &str) -> Result<Option<usize>> {
798 let registry = store::load()?;
799 let resolution = store::resolve(®istry, repo)?;
800 let normalized = store::normalize(&resolution.identity.origin);
801 Ok(criteria
802 .iter()
803 .position(|rule| policy::matches(rule, &normalized, &resolution.publication)))
804}