Skip to main content

opys_engine/
project.rs

1//! The project: `opys.toml` at the project root (found by searching upward),
2//! plus the inventory base it points at (default `opys/`, holding the
3//! document files and `_retired.txt`).
4
5use std::collections::HashSet;
6use std::path::{Path, PathBuf};
7use std::sync::LazyLock;
8
9use regex::Regex;
10
11use crate::doc::Doc;
12use crate::error::{usage, OpysError, Result};
13use crate::project_config::ProjectConfig;
14use crate::refs;
15
16/// The directory to start the `opys.toml` search from: `root` made absolute
17/// (default `.` → the current working directory).
18pub fn start_dir(root: &str) -> Result<PathBuf> {
19    let p = Path::new(root);
20    if p.is_absolute() {
21        Ok(p.to_path_buf())
22    } else {
23        Ok(std::env::current_dir().map_err(OpysError::from)?.join(p))
24    }
25}
26
27/// Walk up from `start` (inclusive) to the filesystem root, returning the first
28/// directory that contains an `opys.toml` — the project root.
29pub fn find_root(start: &Path) -> Option<PathBuf> {
30    let mut cur = start.to_path_buf();
31    loop {
32        if cur.join("opys.toml").is_file() {
33            return Some(cur);
34        }
35        if !cur.pop() {
36            return None;
37        }
38    }
39}
40
41pub struct Project {
42    pub root: PathBuf,
43    pub base: PathBuf,
44    /// The universal engine config (`<root>/opys.toml`), the sole source of
45    /// truth for document types, statuses, fields, sections, and rules.
46    pub pcfg: ProjectConfig,
47}
48
49impl Project {
50    /// Open the project by searching upward from `root` (default the cwd) for
51    /// `opys.toml`. The directory holding it is the project root; the inventory
52    /// base is `<root>/<config.base>` (default `opys`).
53    pub fn open(root: &str) -> Result<Project> {
54        let start = start_dir(root)?;
55        let root = find_root(&start).ok_or_else(|| {
56            usage(format!(
57                "no opys.toml found in {} or any parent directory — run `opys init`",
58                start.display()
59            ))
60        })?;
61        let pcfg = ProjectConfig::load(&root.join("opys.toml"))?;
62        let base = root.join(&pcfg.base);
63        Ok(Project { base, root, pcfg })
64    }
65
66    /// The canonical on-disk path for a document with the given id and status,
67    /// per the configured `layout` (see [`ProjectConfig::doc_relpath`]).
68    pub fn doc_path(&self, id: &str, status: &str) -> PathBuf {
69        self.base.join(self.pcfg.doc_relpath(id, status))
70    }
71
72    /// Highest numeric id part across a document set, their relation maps, and
73    /// the retired ledger — the basis for the single global, monotonically
74    /// increasing id sequence shared by every type.
75    pub fn max_doc_id(&self, docs: &[Doc]) -> u64 {
76        let mut max = 0u64;
77        let mut consider = |id: &str| {
78            if let Some(n) = id_part(id) {
79                max = max.max(n);
80            }
81        };
82        for d in docs {
83            if let Some(id) = d.id() {
84                consider(id);
85            }
86            for id in refs::all_relation_ids(&d.frontmatter) {
87                consider(&id);
88            }
89        }
90        for id in self.retired_ids() {
91            consider(&id);
92        }
93        max
94    }
95
96    /// Next id for a type `prefix`: one past the global max, padded to `pcfg.pad`.
97    pub fn next_id_for(&self, prefix: &str, docs: &[Doc]) -> String {
98        format!(
99            "{}-{:0pad$}",
100            prefix,
101            self.max_doc_id(docs) + 1,
102            pad = self.pcfg.pad
103        )
104    }
105
106    /// IDs that have been retired and may never be reused.
107    pub fn retired_ids(&self) -> HashSet<String> {
108        crate::retired::read(&self.base)
109            .into_iter()
110            .map(|(id, _)| id)
111            .collect()
112    }
113
114    /// Find a document by ID, or a not-found error.
115    pub fn find<'a>(&self, docs: &'a [Doc], id: &str) -> Result<&'a Doc> {
116        docs.iter()
117            .find(|d| d.id() == Some(id))
118            .ok_or_else(|| OpysError::NotFound { id: id.to_string() })
119    }
120}
121
122/// The numeric part of a `PREFIX-NNNN` id, if it parses; `None` for malformed
123/// ids (which the global-sequence max ignores rather than treating as huge).
124fn id_part(id: &str) -> Option<u64> {
125    id.rsplit_once('-').and_then(|(_, n)| n.parse::<u64>().ok())
126}
127
128/// `^PREFIX-\d{pad,}$` — the verify-time id format (pad-or-more digits).
129pub fn id_format_re(prefix: &str, pad: usize) -> Regex {
130    Regex::new(&format!(r"^{}-\d{{{pad},}}$", regex::escape(prefix))).unwrap()
131}
132
133pub static KEBAB_RE: LazyLock<Regex> =
134    LazyLock::new(|| Regex::new(r"^[a-z0-9]+(-[a-z0-9]+)*$").unwrap());
135
136/// Parse a `key=value` custom-field assignment, coercing the value through
137/// YAML (so `n=3` is an int, `t=[a, b]` a list, `s=foo` a string).
138pub fn parse_field(arg: &str) -> Result<(String, serde_norway::Value)> {
139    let (k, v) = arg
140        .split_once('=')
141        .ok_or_else(|| usage(format!("--field expects key=value, got {arg:?}")))?;
142    let value: serde_norway::Value = serde_norway::from_str(v.trim())
143        .unwrap_or_else(|_| serde_norway::Value::String(v.trim().to_string()));
144    Ok((k.trim().to_string(), value))
145}