use std::collections::{BTreeMap, HashSet};
use std::path::{Path, PathBuf};
use regex::Regex;
use serde::Deserialize;
use crate::config::{FieldSpec, FieldType};
use crate::error::{usage, OpysError, Result};
use crate::palette::{parse_color, PaletteEntry};
use crate::refs;
fn default_pad() -> usize {
4
}
fn default_base() -> String {
DEFAULT_BASE.to_string()
}
fn default_roots() -> Vec<String> {
vec![".".to_string()]
}
fn default_min() -> usize {
1
}
fn default_true() -> bool {
true
}
fn default_layout_path() -> String {
DEFAULT_LAYOUT_PATH.to_string()
}
fn unknown_placeholders(template: &str) -> Vec<&str> {
let known = ["type", "status", "id"];
placeholder_names(template)
.into_iter()
.filter(|name| !known.contains(name))
.collect()
}
fn placeholder_names(template: &str) -> Vec<&str> {
let mut out = Vec::new();
let mut rest = template;
while let Some(open) = rest.find('{') {
rest = &rest[open + 1..];
if let Some(close) = rest.find('}') {
let name = &rest[..close];
if !out.contains(&name) {
out.push(name);
}
rest = &rest[close + 1..];
} else {
break;
}
}
out
}
pub const DEFAULT_BASE: &str = "opys";
pub const DEFAULT_DOC_DIR: &str = "";
pub const DEFAULT_LAYOUT_PATH: &str = "{type}/{status}/{id}.md";
#[derive(Debug, Clone, Deserialize)]
pub struct Layout {
#[serde(default = "default_layout_path")]
pub path: String,
}
impl Default for Layout {
fn default() -> Self {
Layout {
path: default_layout_path(),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum SectionKind {
Prose,
Log,
Checklist,
Structured,
}
impl SectionKind {
pub fn is_checkable(self) -> bool {
matches!(self, SectionKind::Checklist)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum CheckScope {
#[default]
All,
Checked,
}
#[derive(Debug, Clone, Deserialize)]
pub struct SectionCheck {
pub pattern: String,
#[serde(default)]
pub file: Option<String>,
#[serde(default = "default_roots")]
pub roots: Vec<String>,
#[serde(default)]
pub must_match: Option<String>,
#[serde(default)]
pub scope: CheckScope,
#[serde(default)]
pub message: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct SectionSpec {
pub heading: String,
pub kind: SectionKind,
#[serde(default)]
pub required: bool,
#[serde(default)]
pub checks: Vec<SectionCheck>,
#[serde(default)]
pub structure: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct LinkReq {
pub to: String,
#[serde(default = "default_min")]
pub min: usize,
}
#[derive(Debug, Clone, Deserialize)]
pub struct DocType {
pub prefix: String,
#[serde(default)]
pub dir: Option<String>,
#[serde(default)]
pub statuses: Vec<String>,
#[serde(default)]
pub status_dirs: BTreeMap<String, String>,
#[serde(default)]
pub default_status: String,
#[serde(default)]
pub terminal_statuses: Vec<String>,
#[serde(default)]
pub tags_required: bool,
#[serde(default)]
pub requires_link: Option<LinkReq>,
#[serde(default)]
pub fields: BTreeMap<String, FieldSpec>,
#[serde(default)]
pub sections: Vec<SectionSpec>,
}
impl DocType {
pub fn resolved_dir(&self) -> &str {
self.dir.as_deref().unwrap_or(DEFAULT_DOC_DIR)
}
pub fn status_dir(&self, status: &str) -> &str {
self.status_dirs.get(status).map_or("", String::as_str)
}
}
#[derive(Debug, Clone, Default, Deserialize)]
pub struct When {
#[serde(default, rename = "type")]
pub doc_type: Option<String>,
#[serde(default)]
pub status: Option<String>,
#[serde(default)]
pub tag: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct AnyTerm {
#[serde(default)]
pub field: Option<String>,
#[serde(default)]
pub link: Option<String>,
#[serde(default)]
pub section: Option<String>,
#[serde(default)]
pub tag: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct FieldMatch {
pub field: String,
pub pattern: String,
}
#[derive(Debug, Clone, Default, Deserialize)]
pub struct Rule {
#[serde(default)]
pub when: When,
#[serde(default)]
pub require_field: Option<String>,
#[serde(default)]
pub require_section: Option<String>,
#[serde(default)]
pub require_checked_section: Option<String>,
#[serde(default)]
pub require_link: Option<LinkReq>,
#[serde(default)]
pub require_any: Option<Vec<AnyTerm>>,
#[serde(default)]
pub field_matches: Option<FieldMatch>,
}
impl Rule {
fn assertion_count(&self) -> usize {
[
self.require_field.is_some(),
self.require_section.is_some(),
self.require_checked_section.is_some(),
self.require_link.is_some(),
self.require_any.is_some(),
self.field_matches.is_some(),
]
.iter()
.filter(|b| **b)
.count()
}
}
pub const BUILTIN_COLUMNS: [&str; 7] = [
"id", "type", "title", "status", "tags", "created", "updated",
];
fn default_columns() -> Vec<String> {
["id", "title", "status", "tags"]
.iter()
.map(|s| s.to_string())
.collect()
}
#[derive(Debug, Clone, Deserialize)]
pub struct TuiConfig {
#[serde(default = "default_columns")]
pub columns: Vec<String>,
}
impl Default for TuiConfig {
fn default() -> Self {
TuiConfig {
columns: default_columns(),
}
}
}
#[derive(Debug, Clone, Deserialize)]
pub struct FileRefs {
#[serde(default = "default_roots")]
pub roots: Vec<String>,
#[serde(default)]
pub formats: Vec<RefFormat>,
}
impl Default for FileRefs {
fn default() -> Self {
FileRefs {
roots: default_roots(),
formats: Vec::new(),
}
}
}
impl FileRefs {
pub fn effective_formats(&self) -> Vec<RefFormat> {
if self.formats.is_empty() {
vec![RefFormat {
template: "{id}".to_string(),
word: true,
}]
} else {
self.formats.clone()
}
}
}
#[derive(Debug, Clone, Deserialize)]
pub struct RefFormat {
pub template: String,
#[serde(default = "default_true")]
pub word: bool,
}
pub const REF_PLACEHOLDERS: [&str; 5] = ["id", "prefix", "prefix_lower", "num", "padded"];
#[derive(Debug, Clone, Deserialize)]
pub struct StatSpec {
pub name: String,
pub sql: String,
#[serde(default)]
pub template: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct ProjectConfig {
#[serde(default = "default_base")]
pub base: String,
#[serde(default = "default_pad")]
pub pad: usize,
#[serde(default)]
pub layout: Layout,
#[serde(default)]
pub types: BTreeMap<String, DocType>,
#[serde(default)]
pub rules: Vec<Rule>,
#[serde(default)]
pub stats: Vec<StatSpec>,
#[serde(default)]
pub palette: BTreeMap<String, PaletteEntry>,
#[serde(default)]
pub tui: TuiConfig,
#[serde(default)]
pub file_refs: FileRefs,
}
impl ProjectConfig {
pub fn load(path: &Path) -> Result<ProjectConfig> {
if !path.exists() {
return Err(usage(format!(
"no opys.toml at {} — run `opys config init`",
path.display()
)));
}
let text = std::fs::read_to_string(path)?;
toml::from_str(&text).map_err(|source| OpysError::Toml {
path: path.to_path_buf(),
source,
})
}
pub fn doc_dirs(&self) -> Vec<&str> {
let mut dirs: Vec<&str> = self.types.values().map(DocType::resolved_dir).collect();
dirs.sort_unstable();
dirs.dedup();
dirs
}
pub fn dir_for_id(&self, id: &str) -> &str {
self.type_name_for_id(id)
.and_then(|n| self.types.get(n))
.map(DocType::resolved_dir)
.unwrap_or(DEFAULT_DOC_DIR)
}
pub fn doc_relpath(&self, id: &str, status: &str) -> PathBuf {
let t = self.type_name_for_id(id).and_then(|n| self.types.get(n));
let type_seg = t.map_or(DEFAULT_DOC_DIR, DocType::resolved_dir);
let status_seg = t.map_or("", |t| t.status_dir(status));
let rendered = self
.layout
.path
.replace("{type}", type_seg)
.replace("{status}", status_seg)
.replace("{id}", id);
rendered.split('/').filter(|seg| !seg.is_empty()).collect()
}
pub fn type_name_for_id(&self, id: &str) -> Option<&str> {
let prefix = id.split_once('-')?.0;
self.types
.iter()
.find(|(_, t)| t.prefix == prefix)
.map(|(name, _)| name.as_str())
}
pub fn validate(&self) -> Vec<String> {
let mut errs = Vec::new();
if self.types.is_empty() {
errs.push("no document types defined ([types.<name>])".into());
}
if !self.layout.path.contains("{id}") {
errs.push("layout.path must contain the {id} placeholder".into());
}
for unknown in unknown_placeholders(&self.layout.path) {
errs.push(format!(
"layout.path: unknown placeholder '{{{unknown}}}' (use type/status/id)"
));
}
let prefix_re = Regex::new(r"^[A-Z][A-Z0-9]*$").unwrap();
let type_names: HashSet<&str> = self.types.keys().map(String::as_str).collect();
let mut seen_prefix: BTreeMap<&str, &str> = BTreeMap::new();
for (name, t) in &self.types {
if !prefix_re.is_match(&t.prefix) {
errs.push(format!(
"type '{name}': prefix '{}' must match ^[A-Z][A-Z0-9]*$",
t.prefix
));
}
if let Some(prev) = seen_prefix.insert(&t.prefix, name) {
errs.push(format!(
"type '{name}': prefix '{}' already used by type '{prev}'",
t.prefix
));
}
if t.statuses.is_empty() {
errs.push(format!("type '{name}': statuses must be non-empty"));
}
if t.default_status.is_empty() {
errs.push(format!("type '{name}': default_status is required"));
} else if !t.statuses.contains(&t.default_status) {
errs.push(format!(
"type '{name}': default_status '{}' not in statuses",
t.default_status
));
}
for s in &t.terminal_statuses {
if !t.statuses.contains(s) {
errs.push(format!(
"type '{name}': terminal_status '{s}' not in statuses"
));
}
}
for s in t.status_dirs.keys() {
if !t.statuses.contains(s) {
errs.push(format!(
"type '{name}': status_dirs key '{s}' is not a status"
));
}
}
if let Some(lr) = &t.requires_link {
if !type_names.contains(lr.to.as_str()) {
errs.push(format!(
"type '{name}': requires_link.to '{}' is not a defined type",
lr.to
));
}
}
for (fname, spec) in &t.fields {
if spec.field_type == FieldType::Enum && spec.values.is_empty() {
errs.push(format!(
"type '{name}' field '{fname}': enum declares no values"
));
}
if let Some(p) = &spec.pattern {
if Regex::new(p).is_err() {
errs.push(format!(
"type '{name}' field '{fname}': pattern is not a valid regex"
));
}
}
}
let mut seen_section: HashSet<&str> = HashSet::new();
for sec in &t.sections {
if !seen_section.insert(sec.heading.as_str()) {
errs.push(format!(
"type '{name}': duplicate section heading '{}'",
sec.heading
));
}
for (ci, chk) in sec.checks.iter().enumerate() {
validate_check(name, &sec.heading, sec.kind, ci + 1, chk, &mut errs);
}
validate_structure(name, sec, &mut errs);
}
}
for (i, rule) in self.rules.iter().enumerate() {
self.validate_rule(i + 1, rule, &type_names, &mut errs);
}
self.validate_palette(&type_names, &mut errs);
self.validate_tui(&mut errs);
self.validate_file_refs(&mut errs);
self.validate_stats(&mut errs);
errs
}
fn validate_stats(&self, errs: &mut Vec<String>) {
let empty = serde_json::json!([]);
for s in &self.stats {
if let Err(e) = crate::commands::stats::render_stat(s, &empty) {
errs.push(e);
}
}
}
fn validate_file_refs(&self, errs: &mut Vec<String>) {
for (i, f) in self.file_refs.formats.iter().enumerate() {
for ph in placeholder_names(&f.template) {
if !REF_PLACEHOLDERS.contains(&ph) {
errs.push(format!(
"file_refs.formats[{i}]: unknown placeholder '{{{ph}}}' (use {})",
REF_PLACEHOLDERS.join("/")
));
}
}
let numbered = ["{id}", "{num}", "{padded}"]
.iter()
.any(|p| f.template.contains(p));
if !numbered {
errs.push(format!(
"file_refs.formats[{i}]: template '{}' must include a number placeholder ({{id}}, {{num}}, or {{padded}})",
f.template
));
}
}
}
fn validate_tui(&self, errs: &mut Vec<String>) {
let known_field = |key: &str| self.types.values().any(|t| t.fields.contains_key(key));
for col in &self.tui.columns {
if !BUILTIN_COLUMNS.contains(&col.as_str()) && !known_field(col) {
errs.push(format!(
"tui.columns: '{col}' is not a built-in column or a declared field"
));
}
}
}
fn validate_palette(&self, types: &HashSet<&str>, errs: &mut Vec<String>) {
for (name, entry) in &self.palette {
if entry.matchers.is_empty() {
errs.push(format!("palette '{name}': needs at least one matcher"));
}
for m in &entry.matchers {
if let Some(t) = &m.doc_type {
if !types.contains(t.as_str()) {
errs.push(format!(
"palette '{name}': matcher type '{t}' is not a defined type"
));
}
}
if let Some(s) = &m.status {
let ok = match &m.doc_type {
Some(t) => self.types.get(t).is_some_and(|dt| dt.statuses.contains(s)),
None => self.types.values().any(|dt| dt.statuses.contains(s)),
};
if !ok {
let scope = m
.doc_type
.as_ref()
.map(|t| format!(" of type '{t}'"))
.unwrap_or_default();
errs.push(format!(
"palette '{name}': matcher status '{s}' is not a status{scope}"
));
}
}
}
for (label, color) in [
("fg_color", &entry.style.fg_color),
("bg_color", &entry.style.bg_color),
] {
if let Some(c) = color {
if parse_color(c).is_none() {
errs.push(format!(
"palette '{name}': {label} '{c}' is not a valid color (a name, #rrggbb, or 0-255)"
));
}
}
}
}
}
fn validate_rule(&self, n: usize, r: &Rule, types: &HashSet<&str>, errs: &mut Vec<String>) {
let tag = format!("rule #{n}");
match r.assertion_count() {
1 => {}
0 => errs.push(format!("{tag}: has no assertion")),
_ => errs.push(format!("{tag}: has more than one assertion")),
}
if let Some(t) = &r.when.doc_type {
if !types.contains(t.as_str()) {
errs.push(format!("{tag}: when.type '{t}' is not a defined type"));
} else if let Some(s) = &r.when.status {
if !self.types[t].statuses.contains(s) {
errs.push(format!(
"{tag}: when.status '{s}' is not a status of type '{t}'"
));
}
}
}
if let Some(lr) = &r.require_link {
if !types.contains(lr.to.as_str()) {
errs.push(format!(
"{tag}: require_link.to '{}' is not a defined type",
lr.to
));
}
}
if let Some(terms) = &r.require_any {
if terms.is_empty() {
errs.push(format!("{tag}: require_any is empty"));
}
for term in terms {
let count = [
term.field.is_some(),
term.link.is_some(),
term.section.is_some(),
term.tag.is_some(),
]
.iter()
.filter(|b| **b)
.count();
if count != 1 {
errs.push(format!(
"{tag}: each require_any term needs exactly one of field/link/section/tag"
));
}
if let Some(l) = &term.link {
if !refs::RELATION_FIELDS.contains(&l.as_str()) {
errs.push(format!(
"{tag}: require_any link '{l}' is not a relation field (references/blocked_by/blocks)"
));
}
}
}
}
if let Some(t) = &r.when.doc_type {
if let Some(dt) = self.types.get(t) {
if let Some(f) = &r.require_field {
if !dt.fields.contains_key(f) {
errs.push(format!(
"{tag}: require_field '{f}' is not a field of type '{t}'"
));
}
}
if let Some(sec) = &r.require_section {
if !dt.sections.iter().any(|s| &s.heading == sec) {
errs.push(format!(
"{tag}: require_section '{sec}' is not a section of type '{t}'"
));
}
}
if let Some(sec) = &r.require_checked_section {
match dt.sections.iter().find(|s| &s.heading == sec) {
None => errs.push(format!(
"{tag}: require_checked_section '{sec}' is not a section of type '{t}'"
)),
Some(s) if !s.kind.is_checkable() => errs.push(format!(
"{tag}: require_checked_section '{sec}' targets a non-checklist section"
)),
_ => {}
}
}
}
}
if let Some(fm) = &r.field_matches {
if Regex::new(&fm.pattern).is_err() {
errs.push(format!("{tag}: field_matches.pattern is not a valid regex"));
}
if let Some(t) = &r.when.doc_type {
if let Some(dt) = self.types.get(t) {
if !dt.fields.contains_key(&fm.field) {
errs.push(format!(
"{tag}: field_matches.field '{}' is not a field of type '{t}'",
fm.field
));
}
}
}
}
}
}
fn validate_structure(type_name: &str, sec: &SectionSpec, errs: &mut Vec<String>) {
let tag = format!("type '{type_name}' section '{}'", sec.heading);
if sec.kind != SectionKind::Structured {
if sec.structure.is_some() {
errs.push(format!(
"{tag}: 'structure' is only allowed on a 'structured' section"
));
}
return;
}
match &sec.structure {
None => errs.push(format!("{tag}: a 'structured' section needs a 'structure'")),
Some(src) => {
if let Err(e) = crate::mdprism::parse_schema(src) {
errs.push(format!("{tag}: invalid structure ({e})"));
}
}
}
}
fn validate_check(
type_name: &str,
heading: &str,
kind: SectionKind,
n: usize,
chk: &SectionCheck,
errs: &mut Vec<String>,
) {
let tag = format!("type '{type_name}' section '{heading}' check #{n}");
let names: HashSet<String> = match Regex::new(&chk.pattern) {
Ok(re) => re
.capture_names()
.flatten()
.map(|s| s.to_string())
.collect(),
Err(_) => {
errs.push(format!("{tag}: pattern is not a valid regex"));
return;
}
};
if let Some(f) = &chk.file {
if !names.contains(f) {
errs.push(format!(
"{tag}: file '{f}' is not a named capture group of pattern"
));
}
}
if chk.file.is_none() && chk.must_match.is_none() {
errs.push(format!("{tag}: needs at least one of file / must_match"));
}
let group_re = Regex::new(r"\$\{(\w+)\}").unwrap();
for tmpl in [chk.must_match.as_deref(), chk.message.as_deref()]
.into_iter()
.flatten()
{
for c in group_re.captures_iter(tmpl) {
let g = &c[1];
if !names.contains(g) {
errs.push(format!(
"{tag}: ${{{g}}} is not a named capture group of pattern"
));
}
}
}
if let Some(mm) = &chk.must_match {
let probe = group_re.replace_all(mm, "x");
if Regex::new(&probe).is_err() {
errs.push(format!("{tag}: must_match is not a valid regex"));
}
}
if chk.scope == CheckScope::Checked && !kind.is_checkable() {
errs.push(format!(
"{tag}: scope = \"checked\" requires a checklist section"
));
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::templates::DEFAULT_OPYS_CONFIG;
#[test]
fn default_config_validates_clean() {
let cfg: ProjectConfig = toml::from_str(DEFAULT_OPYS_CONFIG).unwrap();
let problems = cfg.validate();
assert!(
problems.is_empty(),
"default config has problems: {problems:?}"
);
assert_eq!(cfg.types.len(), 4);
}
#[test]
fn flags_structured_structure_problems() {
let cfg: ProjectConfig = toml::from_str(
r#"
[types.feature]
prefix = "FEAT"
statuses = ["planned"]
default_status = "planned"
[[types.feature.sections]]
heading = "Empty"
kind = "structured"
[[types.feature.sections]]
heading = "Bad"
kind = "structured"
structure = "this is not a marker"
[[types.feature.sections]]
heading = "Notes"
kind = "prose"
structure = "- @x"
"#,
)
.unwrap();
let joined = cfg.validate().join("\n");
assert!(
joined.contains("section 'Empty': a 'structured' section needs a 'structure'"),
"{joined}"
);
assert!(
joined.contains("section 'Bad': invalid structure"),
"{joined}"
);
assert!(
joined
.contains("section 'Notes': 'structure' is only allowed on a 'structured' section"),
"{joined}"
);
}
#[test]
fn flags_unknown_tui_column() {
let cfg: ProjectConfig = toml::from_str(
r#"
[types.feature]
prefix = "FEAT"
statuses = ["planned"]
default_status = "planned"
[types.feature.fields.priority]
type = "string"
[tui]
columns = ["id", "title", "priority", "bogus"]
"#,
)
.unwrap();
let joined = cfg.validate().join("\n");
assert!(joined.contains("tui.columns: 'bogus'"), "{joined}");
assert!(!joined.contains("'priority'"), "{joined}");
assert!(!joined.contains("'id'"), "{joined}");
}
#[test]
fn flags_palette_unknown_type_status_and_bad_color() {
let cfg: ProjectConfig = toml::from_str(
r#"
[types.feature]
prefix = "FEAT"
statuses = ["planned", "done"]
default_status = "planned"
[palette.ghost]
matchers = [ { type = "ghost" } ]
[palette.badstatus]
matchers = [ { status = "nope" } ]
[palette.typedstatus]
matchers = [ { type = "feature", status = "nope" } ]
[palette.badcolor]
matchers = [ { status = "done" } ]
[palette.badcolor.style]
fg_color = "rainbow"
[palette.empty]
matchers = []
"#,
)
.unwrap();
let joined = cfg.validate().join("\n");
assert!(
joined.contains("matcher type 'ghost' is not a defined type"),
"{joined}"
);
assert!(
joined.contains("matcher status 'nope' is not a status\n")
|| joined.contains("matcher status 'nope' is not a status of"),
"{joined}"
);
assert!(
joined.contains("matcher status 'nope' is not a status of type 'feature'"),
"{joined}"
);
assert!(
joined.contains("fg_color 'rainbow' is not a valid color"),
"{joined}"
);
assert!(
joined.contains("palette 'empty': needs at least one matcher"),
"{joined}"
);
}
#[test]
fn flags_bad_default_status_and_duplicate_prefix_and_unknown_rule_type() {
let cfg: ProjectConfig = toml::from_str(
r#"
[types.feature]
prefix = "FEAT"
statuses = ["planned"]
default_status = "nope"
[types.bug]
prefix = "FEAT"
statuses = ["todo"]
default_status = "todo"
[[rules]]
when = { type = "ghost" }
require_field = "x"
"#,
)
.unwrap();
let problems = cfg.validate();
let joined = problems.join("\n");
assert!(
joined.contains("default_status 'nope' not in statuses"),
"{joined}"
);
assert!(joined.contains("already used by type"), "{joined}");
assert!(
joined.contains("when.type 'ghost' is not a defined type"),
"{joined}"
);
}
}