1use 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
16pub 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
27pub 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 pub pcfg: ProjectConfig,
47}
48
49impl Project {
50 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 pub fn doc_path(&self, id: &str, status: &str) -> PathBuf {
69 self.base.join(self.pcfg.doc_relpath(id, status))
70 }
71
72 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 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 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 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
122fn id_part(id: &str) -> Option<u64> {
125 id.rsplit_once('-').and_then(|(_, n)| n.parse::<u64>().ok())
126}
127
128pub 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
136pub 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}