Skip to main content

onevcs/
workspaces.rs

1//! The workspaces file: how many warm worktree slots each repository keeps on this
2//! host, and how many sessions it admits past them.
3//!
4//! YAML, first match wins, matched on the same [`RuleMatch`] the rules file and the
5//! release-targets file use — a third match vocabulary over the same identities would
6//! drift. A separate file beside `rules.yml` and `releases.yml` rather than a key in
7//! either, so an older `onevcs` sharing the host reads a byte-identical registry and
8//! rules file and simply keeps opening fresh sessions.
9//!
10//! **Absent, every value is the shipped default, which is today's behaviour exactly:**
11//! `pool: 0` (every session is cut fresh under `runs/`), `overflow: unlimited`, nothing
12//! deleted on return, and no maintenance. A host that never writes this file is
13//! unchanged.
14//!
15//! Nothing here declares `deny_unknown_fields`, for the reason the other two host files
16//! do not: a document a *newer* build wrote loads on this one, which takes the keys it
17//! understands and ignores the rest, because an older `onevcs` refusing an operator's
18//! whole file would stop every session on the host.
19
20use 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
31/// The version of the workspaces file this build writes, and the oldest it reads.
32///
33/// The oldest rather than the newest, exactly as the release-targets file: a document
34/// declaring a *later* version is read as this shape with whatever it names beyond it
35/// ignored, because refusing it would stop every session on a host a newer `onevcs`
36/// had configured. A lower one is refused by number.
37pub const VERSION: u32 = 1;
38
39/// The bound a maintenance command runs under when the document names none.
40pub const DEFAULT_MAINTAIN_TIMEOUT: &str = "30m";
41
42/// The environment variable that overrides the resolved pool size for one process.
43pub const POOL_ENV: &str = "ONEVCS_POOL";
44
45/// The environment variable that overrides the resolved overflow bound for one process.
46pub const OVERFLOW_ENV: &str = "ONEVCS_OVERFLOW";
47
48/// A duration as this crate's documents spell one: one or more digits, then exactly one
49/// unit letter — `7d`, `36h`, `90m`, `600s`.
50///
51/// One grammar rather than a free one, and deliberately narrow: no spaces, no
52/// combination (`1h30m`), no fraction, no sign, and no zero, because every place a span
53/// is written names a wait or a bound and a bound of nothing is a mistake rather than a
54/// choice. The check is in the conversion, so a document naming a span this grammar
55/// refuses does not deserialize at all. It is exported because a consumer parses its own
56/// schedule through it, so the two cannot come to read one spelling two ways.
57#[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
91/// What every refusal of a span ends with: the grammar, so the operator can fix the
92/// spelling without looking it up.
93const 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    /// The duration this span names.
99    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
153/// As written: the number and its unit letter, so a document round-trips byte for byte.
154impl 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
160/// The span itself, quoted — never the wrapper, for the reason every other document
161/// value in this crate spells its own `Debug` that way.
162impl 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/// A count that may be unbounded: `unlimited`, or a non-negative integer.
182///
183/// What the overflow is spelled as, and what the pool answers "how many more" in. In a
184/// document and on `--overflow` it is the word or the integer; in JSON it is the string
185/// `"unlimited"` or the number, so a consumer reading `pool status --json` never has to
186/// guess which sentinel integer means none.
187#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
188pub enum Bound {
189    /// No bound at all.
190    Unlimited,
191    /// At most this many.
192    Bounded(u32),
193}
194
195impl Bound {
196    /// The word or the number, as a document and a report spell it.
197    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    /// Whether `used` of this bound leaves room for one more.
205    pub fn admits(&self, used: u32) -> bool {
206        match self {
207            Bound::Unlimited => true,
208            Bound::Bounded(count) => used < *count,
209        }
210    }
211
212    /// How many more this bound admits past `used`.
213    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/// The two spellings a bound arrives in, before either has been checked.
255#[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/// A workspaces file, as it is stored on disk.
274#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
275pub struct WorkspacesFile {
276    /// The schema version: `1` is the shape declared here.
277    // llmlint: ignore[boundary_inputs_validated] which versions this build reads is the
278    // loader's question rather than this type's, and `load` answers it: a document below
279    // this one is refused by number before the shape is enforced, and a later one is read
280    // as this shape. What the shape can reject — a span or a bound that is not one — is
281    // rejected in those types' own conversions, and the combinations only a whole document
282    // can get wrong are refused by `validate`.
283    pub version: u32,
284    /// What a repository no rule below names gets, and what a rule that leaves a field
285    /// unset falls to. Absent, every field is the shipped default.
286    #[serde(default)]
287    pub default: WorkspaceDefault,
288    /// The rules, in priority order: the first one that matches wins.
289    #[serde(default, skip_serializing_if = "Vec::is_empty")]
290    pub rules: Vec<WorkspaceRule>,
291}
292
293/// A complete workspace policy: every field a rule may set, all of them decided.
294#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
295pub struct WorkspaceDefault {
296    /// How many warm slots the identity keeps. `0` is pooling off: every session is cut
297    /// fresh, exactly as a host with no file opens one.
298    #[serde(default)]
299    pub pool: u32,
300    /// How many sessions the identity admits *past* its pool, each cut fresh under
301    /// `runs/`. `unlimited` never refuses an open.
302    #[serde(default = "unlimited")]
303    pub overflow: Bound,
304    /// Worktree-relative paths deleted on every return: state that would otherwise leak
305    /// from one session into the next. An absent path is nothing to delete.
306    #[serde(default, skip_serializing_if = "Vec::is_empty")]
307    pub delete: Vec<PathBuf>,
308    /// What maintenance of an idle slot is, for the verb that performs it. Absent, no
309    /// maintenance is ever run.
310    #[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    /// The shipped default, which is a host with no file at all.
320    fn default() -> Self {
321        WorkspaceDefault {
322            pool: 0,
323            overflow: Bound::Unlimited,
324            delete: Vec::new(),
325            maintain: None,
326        }
327    }
328}
329
330/// One rule: what it matches, and which parts of the policy it sets.
331#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
332pub struct WorkspaceRule {
333    /// What this rule applies to, in the rules file's own vocabulary.
334    #[serde(rename = "match")]
335    pub r#match: RuleMatch,
336    /// The pool size. Unset falls to the default.
337    #[serde(default, skip_serializing_if = "Option::is_none")]
338    pub pool: Option<u32>,
339    /// The overflow bound. Unset falls to the default.
340    #[serde(default, skip_serializing_if = "Option::is_none")]
341    pub overflow: Option<Bound>,
342    /// The paths deleted on return. Unset falls to the default; set, it replaces it.
343    #[serde(default, skip_serializing_if = "Option::is_none")]
344    pub delete: Option<Vec<PathBuf>>,
345    /// What maintenance is. Unset falls to the default.
346    #[serde(default, skip_serializing_if = "Option::is_none")]
347    pub maintain: Option<Maintenance>,
348}
349
350/// What maintaining an idle slot means: a command, and the bound it runs under.
351#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
352pub struct Maintenance {
353    /// The argv, spawned in the slot's worktree **with no shell**.
354    pub command: MaintenanceCommand,
355    /// The bound the command runs under. Absent, [`DEFAULT_MAINTAIN_TIMEOUT`].
356    #[serde(default = "default_timeout")]
357    pub timeout: Span,
358}
359
360/// The argv of a maintenance command: the program, and the arguments it is spawned
361/// with.
362///
363/// A program *beside* its arguments rather than one list, because a list can be empty
364/// and an argv cannot — there is nothing to spawn without a program — so a caller that
365/// builds a rule in memory is refused by the type rather than at a validation it has
366/// not reached yet.
367///
368/// The document's own form is unchanged: a non-empty sequence whose first element is
369/// the program and whose rest are its arguments, spawned **with no shell**, so `&&`,
370/// pipes and globs are passed through literally rather than composed. A host that
371/// wants composition writes a script and names it here. Both ways a document can
372/// name nothing to spawn — an empty sequence, and a first element that is the empty
373/// string — are refused where it deserializes, naming the key; what this build
374/// writes is the sequence it read.
375#[derive(Debug, Clone, PartialEq, Eq)]
376pub struct MaintenanceCommand {
377    // llmlint: ignore-block[invalid_states_unrepresentable] the amendment fixes this
378    // field as `pub program: String`, the way it fixes `SlotState::Maintaining`'s
379    // `since`, and the other nodes building against this release read that spelling —
380    // so a non-empty string newtype here is a change to the declared surface rather
381    // than this crate's to make alone, and it is put to that surface's owner rather
382    // than taken. What the type cannot say, the trust boundary does: every program
383    // that arrives from a document comes through the `Deserialize` below, which
384    // refuses an empty one by name, so nothing a host wrote reaches a spawn with no
385    // program. Block-scoped rather than line-scoped because a line-scoped directive
386    // covers only the line below it, which a reason this long would make a comment.
387    /// The program, spawned in the slot's worktree. Never empty.
388    pub program: String,
389    // llmlint: ignore-end[invalid_states_unrepresentable]
390    /// Its arguments, in the order the sequence spelled them. An argument may be the
391    /// empty string — a program is entitled to take one — which is why only the
392    /// program is held to being non-empty.
393    pub args: Vec<String>,
394}
395
396/// What the refusal of an empty command says, naming the key an operator edits.
397const EMPTY_COMMAND: &str =
398    "maintain.command is an empty list: name the program to run and its arguments";
399
400/// And what the refusal of one whose program is the empty string says, which is the
401/// same absence spelled a second way: a sequence of one empty element is a list that
402/// is not empty and still names nothing to spawn.
403const EMPTY_PROGRAM: &str =
404    "maintain.command names an empty program: its first element is the program to run";
405
406impl MaintenanceCommand {
407    /// The program and its arguments as one sequence, which is what a document spells
408    /// and what a spawn takes.
409    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
416/// The sequence the host wrote, so a document round-trips as it stands.
417impl 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
449/// Where a host configures its pools: one conventional path under the state root,
450/// and nowhere else — not reachable through the registry, for the reason the
451/// release-targets file is not.
452pub fn default_path() -> Result<PathBuf> {
453    Ok(home::root()?.join("workspaces.yml"))
454}
455
456/// Where the values a repository resolved to came from.
457///
458/// Two states rather than one string, as the rules file's own source is: a *path*
459/// that was read, and the absence of any file at all. A refusal that tells an operator
460/// which file to edit has to tell those apart.
461#[derive(Debug, Clone, PartialEq, Eq)]
462pub(crate) enum FileSource {
463    /// The workspaces file at this path, which is what was read.
464    File(PathBuf),
465    /// No file: the shipped default.
466    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
478/// Load this host's workspaces file, or the shipped default.
479///
480/// A host with no such file behaves exactly as it did before there was one: every
481/// session is cut fresh and nothing is ever refused for want of room.
482pub(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    // The version is read before the shape is enforced, and refused before it too, for
508    // the reason the other two host files do it: which keys a document may carry is a
509    // fact about the version it declares. Only a version *below* this one is refused.
510    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
527/// Reject a document whose own values cannot be honoured.
528///
529/// Two things only a whole document gets wrong. A `delete` entry that is not a
530/// relative path *inside* the worktree — absolute, or climbing out through `..` — would
531/// have a return deleting something outside the slot, so it is refused where the
532/// document is read rather than where the return happens. And a `pool: 0` beside an
533/// `overflow: 0` admits no session at all, which is refused here naming where, and
534/// again at open where the two arrive separately. A maintenance command with no argv
535/// is not one of them: [`MaintenanceCommand`] cannot hold one, so the empty sequence
536/// is refused where the document deserializes, before a policy resolves to it.
537fn 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
577/// Why a `delete` entry could reach outside the worktree, or `None` where it stays in.
578fn 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/// A value the resolution decided, and where it came from.
597#[derive(Debug, Clone, PartialEq, Eq)]
598pub(crate) struct Sourced<T> {
599    /// The decided value.
600    pub value: T,
601    /// Which layer decided it, for a refusal that names its sources.
602    pub from: String,
603}
604
605/// The per-open overrides the request carries above every layer of the file.
606#[derive(Debug, Clone, Copy, Default)]
607pub(crate) struct Overrides {
608    pub pool: Option<u32>,
609    pub overflow: Option<Bound>,
610}
611
612/// What one repository resolves to, every layer applied.
613#[derive(Debug, Clone)]
614pub(crate) struct Resolved {
615    /// The pool size the open places against.
616    pub pool: Sourced<u32>,
617    /// The overflow bound the open counts against.
618    pub overflow: Sourced<Bound>,
619    /// The pool size as the host's **file** alone resolves it — the matching rule, else
620    /// `default:`, else the shipped value — which is what surplus slots are measured
621    /// against. Neither `--pool` on one open nor `ONEVCS_POOL` in the environment sheds
622    /// anything: both are per-process, and a per-process override that removed slots
623    /// would take the warm pool down every time a dispatch was handed one.
624    pub file_pool: u32,
625    /// The paths deleted on return.
626    pub delete: Vec<PathBuf>,
627    /// What maintaining an idle slot is, for the verb that performs it. `None` is no
628    /// maintenance ever.
629    pub maintain: Option<Maintenance>,
630    /// Whether anything about this identity departs from the shipped default: a pool
631    /// above nought, a bounded overflow, a delete list, or maintenance. A host that
632    /// departs in nothing takes today's path exactly.
633    pub configured: bool,
634}
635
636/// Resolve the workspace policy for one repository, highest layer winning: the
637/// request's own overrides, then the environment, then the first matching rule, then
638/// the file's `default:`, then the shipped default.
639///
640/// A `pool: 0` beside an `overflow: 0` is refused *here* as well as where the file is
641/// read, because the two can arrive from different layers — `--pool 0` over a file whose
642/// overflow is `0`, say — and the refusal names the identity and where each came from.
643pub(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
740/// Resolve the workspace policy for one resolved repository argument.
741pub(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
756/// The pool size the environment names for this process, refused where it is not one.
757fn 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
773/// The overflow bound the environment names for this process, refused where it is not
774/// one.
775fn 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
788/// The index of the first of `criteria` that matches `repo`, by exactly the matcher the
789/// rules, releases and workspaces files use.
790///
791/// `repo` is resolved through the registry the way every repository-taking read is — an
792/// identity key, a registered alias, an origin URL, or a path — and one that resolves to
793/// no registered identity is refused rather than answered `None`, because "no rule
794/// matches" and "this is not a repository I know" are different answers to act on. A
795/// consumer matching its own document's rules on the same vocabulary asks this rather
796/// than restating the matcher, so the two cannot come to read one `match:` two ways.
797pub fn first_matching(criteria: &[RuleMatch], repo: &str) -> Result<Option<usize>> {
798    let registry = store::load()?;
799    let resolution = store::resolve(&registry, repo)?;
800    let normalized = store::normalize(&resolution.identity.origin);
801    Ok(criteria
802        .iter()
803        .position(|rule| policy::matches(rule, &normalized, &resolution.publication)))
804}