use std::fs::{self, OpenOptions};
use std::io::Write;
use std::path::{Component, Path, PathBuf};
use regex::Regex;
use serde::{Deserialize, Serialize};
use thiserror::Error;
use tracing::trace;
use indexmap::IndexMap;
use crate::identifier::{EntryAddress, EntryAtom};
pub const CONFIG_FILE_NAME: &str = "Sirno.toml";
macro_rules! witness_entry_address_capture_regex {
() => {
r#"([^\x00-\x1F\x7F<>:"/\\|?*,\r\n]+)"#
};
}
pub const WITNESS_ENTRY_ADDRESS_CAPTURE_REGEX: &str = witness_entry_address_capture_regex!();
pub const STANDARD_LINE_WITNESS_BEGIN_REGEX: &str = concat!(
r"(?m)^[ \t]*//[ \t]*sirno:witness:",
witness_entry_address_capture_regex!(),
r":begin"
);
pub const STANDARD_LINE_WITNESS_END_REGEX: &str =
concat!(r"(?m)^[ \t]*//[ \t]*sirno:witness:", witness_entry_address_capture_regex!(), r":end");
pub const STANDARD_MARKDOWN_WITNESS_BEGIN_REGEX: &str = concat!(
r"(?m)^[ \t]*<!--[ \t]*sirno:witness:",
witness_entry_address_capture_regex!(),
r":begin[ \t]*-->"
);
pub const STANDARD_MARKDOWN_WITNESS_END_REGEX: &str = concat!(
r"(?m)^[ \t]*<!--[ \t]*sirno:witness:",
witness_entry_address_capture_regex!(),
r":end[ \t]*-->"
);
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct CheckSettings {
#[serde(skip_serializing_if = "Option::is_none")]
pub render: Option<bool>,
}
impl CheckSettings {
pub fn render_enabled(&self) -> bool {
self.render.unwrap_or(true)
}
fn has_explicit_flags(&self) -> bool {
self.render.is_some()
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct TutorialSettings {
pub anchor_update_tide: bool,
pub anchor_bootstrap_tide: bool,
}
impl TutorialSettings {
pub fn all() -> Self {
Self { anchor_update_tide: true, anchor_bootstrap_tide: true }
}
}
impl Default for TutorialSettings {
fn default() -> Self {
Self::all()
}
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct CharmSettings {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub enabled: Vec<EntryAddress>,
}
impl CharmSettings {
pub fn is_empty(&self) -> bool {
self.enabled.is_empty()
}
pub fn contains(&self, id: &EntryAddress) -> bool {
self.enabled.iter().any(|enabled| enabled == id)
}
pub fn enable(&mut self, id: EntryAddress) -> bool {
if self.contains(&id) {
return false;
}
self.enabled.push(id);
true
}
pub fn disable(&mut self, id: &EntryAddress) -> bool {
let before = self.enabled.len();
self.enabled.retain(|enabled| enabled != id);
self.enabled.len() != before
}
fn validate(&self) -> Result<(), ConfigError> {
for (index, id) in self.enabled.iter().enumerate() {
if self.enabled.iter().skip(index + 1).any(|other| other == id) {
return Err(ConfigError::DuplicateCharmEnabled(id.clone()));
}
}
Ok(())
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct LakeSettings {
pub path: PathBuf,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub ignore: Vec<PathBuf>,
}
impl LakeSettings {
pub fn new(path: impl Into<PathBuf>) -> Self {
Self { path: path.into(), ignore: Vec::new() }
}
fn validate(&self) -> Result<(), ConfigError> {
for path in &self.ignore {
if path.as_os_str().is_empty()
|| path.is_absolute()
|| path.components().any(|component| {
matches!(
component,
Component::ParentDir | Component::RootDir | Component::Prefix(_)
)
})
{
return Err(ConfigError::LakeIgnorePath(path.clone()));
}
}
Ok(())
}
}
pub type UpstreamSettingsMap = IndexMap<EntryAtom, UpstreamSettings>;
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct UpstreamSettings {
pub git: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub branch: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tag: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub rev: Option<String>,
#[serde(default = "default_upstream_project", skip_serializing_if = "is_default_project")]
pub project: PathBuf,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub mist: Option<EntryAtom>,
}
impl UpstreamSettings {
pub fn branch(git: impl Into<String>, branch: impl Into<String>) -> Self {
Self {
git: git.into(),
branch: Some(branch.into()),
tag: None,
rev: None,
project: default_upstream_project(),
mist: None,
}
}
pub fn tag(git: impl Into<String>, tag: impl Into<String>) -> Self {
Self {
git: git.into(),
branch: None,
tag: Some(tag.into()),
rev: None,
project: default_upstream_project(),
mist: None,
}
}
pub fn rev(git: impl Into<String>, rev: impl Into<String>) -> Self {
Self {
git: git.into(),
branch: None,
tag: None,
rev: Some(rev.into()),
project: default_upstream_project(),
mist: None,
}
}
pub fn with_mist(mut self, mist: EntryAtom) -> Self {
self.mist = Some(mist);
self
}
pub fn selector(&self) -> UpstreamRef<'_> {
if let Some(branch) = &self.branch {
UpstreamRef::Branch(branch)
} else if let Some(tag) = &self.tag {
UpstreamRef::Tag(tag)
} else if let Some(rev) = &self.rev {
UpstreamRef::Rev(rev)
} else {
panic!("validated upstream settings always have one selector")
}
}
fn validate(&self, domain: &EntryAtom) -> Result<(), ConfigError> {
if self.git.trim().is_empty() {
return Err(ConfigError::UpstreamGitSource(domain.clone()));
}
let ref_count = [self.branch.as_ref(), self.tag.as_ref(), self.rev.as_ref()]
.into_iter()
.flatten()
.count();
if ref_count != 1 {
return Err(ConfigError::UpstreamRefSelector(domain.clone()));
}
for selector in
[self.branch.as_ref(), self.tag.as_ref(), self.rev.as_ref()].into_iter().flatten()
{
if selector.trim().is_empty() {
return Err(ConfigError::UpstreamRefSelector(domain.clone()));
}
}
validate_upstream_project_path(domain, &self.project)?;
Ok(())
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum UpstreamRef<'a> {
Branch(&'a str),
Tag(&'a str),
Rev(&'a str),
}
fn default_upstream_project() -> PathBuf {
PathBuf::from(".")
}
fn is_default_project(path: &PathBuf) -> bool {
path == &default_upstream_project()
}
fn validate_upstream_project_path(domain: &EntryAtom, path: &Path) -> Result<(), ConfigError> {
if path.as_os_str().is_empty() || path.is_absolute() {
return Err(ConfigError::UpstreamProjectPath { domain: domain.clone(), path: path.into() });
}
for component in path.components() {
if matches!(component, Component::ParentDir | Component::RootDir | Component::Prefix(_)) {
return Err(ConfigError::UpstreamProjectPath {
domain: domain.clone(),
path: path.into(),
});
}
}
Ok(())
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(transparent)]
pub struct RepoMember {
pattern: String,
}
impl RepoMember {
pub fn new(pattern: impl Into<String>) -> Result<Self, ConfigError> {
let member = Self { pattern: pattern.into() };
member.validate()?;
Ok(member)
}
pub fn as_str(&self) -> &str {
&self.pattern
}
fn validate(&self) -> Result<(), ConfigError> {
let path = Path::new(&self.pattern);
if self.pattern.is_empty()
|| path.is_absolute()
|| path.components().any(|component| {
matches!(
component,
Component::ParentDir | Component::RootDir | Component::Prefix(_)
)
})
{
return Err(ConfigError::RepoMemberPath(self.pattern.clone()));
}
Ok(())
}
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct RepoSettings {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub members: Vec<RepoMember>,
}
impl RepoSettings {
fn validate(&self) -> Result<(), ConfigError> {
for member in &self.members {
member.validate()?;
}
Ok(())
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct WitnessDelimiterSettings {
pub begin: String,
pub end: String,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct WitnessSettings {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub delimiters: Vec<WitnessDelimiterSettings>,
}
impl WitnessDelimiterSettings {
pub fn new(begin: impl Into<String>, end: impl Into<String>) -> Self {
Self { begin: begin.into(), end: end.into() }
}
fn validate(&self, index: usize) -> Result<(), ConfigError> {
Self::validate_regex("witness.delimiters.begin", index, &self.begin)?;
Self::validate_regex("witness.delimiters.end", index, &self.end)?;
Ok(())
}
fn validate_regex(field: &'static str, index: usize, source: &str) -> Result<(), ConfigError> {
if source.trim().is_empty() {
return Err(ConfigError::WitnessRegex { field, index });
}
let regex = Regex::new(source).map_err(|source| ConfigError::WitnessRegexSyntax {
field,
index,
source,
})?;
if regex.captures_len() < 2 {
return Err(ConfigError::WitnessRegexCapture { field, index });
}
if regex.is_match("") {
return Err(ConfigError::WitnessRegexEmptyMatch { field, index });
}
Ok(())
}
}
impl WitnessSettings {
pub fn standard() -> Self {
Self {
delimiters: vec![
WitnessDelimiterSettings::new(
STANDARD_LINE_WITNESS_BEGIN_REGEX,
STANDARD_LINE_WITNESS_END_REGEX,
),
WitnessDelimiterSettings::new(
STANDARD_MARKDOWN_WITNESS_BEGIN_REGEX,
STANDARD_MARKDOWN_WITNESS_END_REGEX,
),
],
}
}
fn validate(&self) -> Result<(), ConfigError> {
for (index, delimiter) in self.delimiters.iter().enumerate() {
delimiter.validate(index)?;
}
Ok(())
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct SirnoConfig {
pub lake: LakeSettings,
#[serde(default, skip_serializing_if = "IndexMap::is_empty")]
pub upstreams: UpstreamSettingsMap,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub repo: Option<RepoSettings>,
pub witness: WitnessSettings,
#[serde(default)]
pub check: CheckSettings,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tutorial: Option<TutorialSettings>,
#[serde(default, skip_serializing_if = "CharmSettings::is_empty")]
pub charm: CharmSettings,
}
impl SirnoConfig {
pub fn new(lake: impl Into<PathBuf>) -> Self {
Self {
lake: LakeSettings::new(lake),
upstreams: UpstreamSettingsMap::new(),
repo: None,
witness: WitnessSettings::standard(),
check: CheckSettings::default(),
tutorial: None,
charm: CharmSettings::default(),
}
}
pub fn with_lake(mut self, lake: impl Into<PathBuf>) -> Self {
self.lake.path = lake.into();
self
}
pub fn with_tutorial(mut self) -> Self {
self.tutorial = Some(TutorialSettings::all());
self
}
pub fn with_upstream(mut self, domain: EntryAtom, settings: UpstreamSettings) -> Self {
self.upstreams.insert(domain, settings);
self
}
pub fn remove_upstream(&mut self, domain: &EntryAtom) -> Option<UpstreamSettings> {
self.upstreams.shift_remove(domain)
}
pub fn default_project() -> Self {
Self::new("docs")
}
pub fn from_file(path: impl AsRef<Path>) -> Result<Self, ConfigError> {
let path = path.as_ref();
trace!("sirno config load begin: path={}", path.display());
let source = fs::read_to_string(path)
.map_err(|source| ConfigError::Read { path: path.to_path_buf(), source })?;
let config = Self::from_source(path, &source)?;
trace!("sirno config load end");
Ok(config)
}
pub fn from_source(path: impl AsRef<Path>, source: &str) -> Result<Self, ConfigError> {
let path = path.as_ref();
let config: Self = toml::from_str(source)
.map_err(|source| ConfigError::Parse { path: path.to_path_buf(), source })?;
config.validate_for_file(path)?;
Ok(config)
}
pub fn write_new(&self, path: impl AsRef<Path>) -> Result<(), ConfigError> {
let path = path.as_ref();
trace!("sirno config write begin: path={}", path.display());
self.validate_for_file(path)?;
let source = self.to_toml()?;
let mut file = OpenOptions::new()
.write(true)
.create_new(true)
.open(path)
.map_err(|source| ConfigError::Create { path: path.to_path_buf(), source })?;
file.write_all(source.as_bytes())
.map_err(|source| ConfigError::Write { path: path.to_path_buf(), source })?;
trace!("sirno config write end");
Ok(())
}
pub fn write(&self, path: impl AsRef<Path>) -> Result<(), ConfigError> {
let path = path.as_ref();
trace!("sirno config write replace begin: path={}", path.display());
self.validate_for_file(path)?;
let source = self.to_toml()?;
let mut file = OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.open(path)
.map_err(|source| ConfigError::Create { path: path.to_path_buf(), source })?;
file.write_all(source.as_bytes())
.map_err(|source| ConfigError::Write { path: path.to_path_buf(), source })?;
trace!("sirno config write replace end");
Ok(())
}
pub fn to_commented_toml(&self) -> Result<String, ConfigError> {
self.to_toml()
}
pub fn missing_comments_in(&self, source: &str) -> Result<Vec<String>, ConfigError> {
let expected = self
.to_commented_toml()?
.lines()
.filter_map(|line| line.strip_prefix("# ").map(str::to_owned))
.collect::<Vec<_>>();
let current = source.lines().map(str::trim).collect::<Vec<_>>();
Ok(expected
.into_iter()
.filter(|comment| {
let line = format!("# {comment}");
!current.iter().any(|current| *current == line)
})
.collect())
}
pub fn resolve_lake(&self, config_path: impl AsRef<Path>) -> PathBuf {
Self::resolve_config_relative(config_path.as_ref(), &self.lake.path)
}
pub fn validate_for_file(&self, _config_path: impl AsRef<Path>) -> Result<(), ConfigError> {
self.lake.validate()?;
if let Some(repo) = &self.repo {
repo.validate()?;
}
for (domain, upstream) in &self.upstreams {
upstream.validate(domain)?;
}
self.witness.validate()?;
self.charm.validate()?;
Ok(())
}
fn to_toml(&self) -> Result<String, ConfigError> {
ConfigRenderer::render(self).map_err(ConfigError::Render)
}
fn resolve_config_relative(config_path: &Path, configured_path: &Path) -> PathBuf {
if configured_path.is_absolute() {
return configured_path.to_path_buf();
}
config_path.parent().unwrap_or_else(|| Path::new(".")).join(configured_path)
}
}
struct ConfigRenderer {
out: String,
}
impl ConfigRenderer {
fn render(config: &SirnoConfig) -> Result<String, toml::ser::Error> {
let mut renderer = Self { out: String::new() };
renderer.push_config(config)?;
Ok(renderer.out)
}
fn push_config(&mut self, config: &SirnoConfig) -> Result<(), toml::ser::Error> {
self.push_table("lake");
self.push_field(
"path",
&config.lake.path,
"Sirno Lake path, resolved relative to this config file.",
)?;
if !config.lake.ignore.is_empty() {
self.push_field(
"ignore",
&config.lake.ignore,
"Paths in lake that Sirno skips while reading, checking, querying, and rendering footers.",
)?;
}
if !config.upstreams.is_empty() {
self.out.push('\n');
self.push_upstreams(&config.upstreams)?;
}
if let Some(repo) = &config.repo {
self.out.push('\n');
self.push_table("repo");
self.push_field(
"members",
&repo.members,
"Repository files, directories, or globs scanned for witness blocks.",
)?;
}
self.out.push('\n');
self.push_table("witness");
self.push_witness_delimiters(&config.witness.delimiters)?;
if config.check.has_explicit_flags() {
self.out.push('\n');
self.push_table("check");
if let Some(render) = config.check.render {
self.push_field(
"render",
&render,
"Require generated footers to match current metadata during checks.",
)?;
}
}
if let Some(tutorial) = config.tutorial {
self.out.push('\n');
self.push_table("tutorial");
self.out.push_str(
"# Presence of this table enables tutorial text for recoverable command failures.\n",
);
self.out.push_str("# Remove this table to keep CLI errors terse.\n");
self.push_field(
"anchor_update_tide",
&tutorial.anchor_update_tide,
"Show tutorial text when anchor update is blocked by open tide workitems.",
)?;
self.push_field(
"anchor_bootstrap_tide",
&tutorial.anchor_bootstrap_tide,
"Include first-anchor bootstrap context in the anchor update tide tutorial.",
)?;
}
if !config.charm.is_empty() {
self.out.push('\n');
self.push_table("charm");
self.push_field(
"enabled",
&config.charm.enabled,
"Entry addresses whose charm manifests may resolve and invoke spells.",
)?;
}
Ok(())
}
fn push_table(&mut self, name: &str) {
self.out.push('[');
self.out.push_str(name);
self.out.push_str("]\n");
}
fn push_field<T: Serialize + ?Sized>(
&mut self, name: &str, value: &T, comment: &str,
) -> Result<(), toml::ser::Error> {
self.out.push_str("# ");
self.out.push_str(comment);
self.out.push('\n');
self.out.push_str(name);
self.out.push_str(" = ");
self.out.push_str(&Self::toml_value(value)?);
self.out.push('\n');
Ok(())
}
fn push_witness_delimiters(
&mut self, delimiters: &[WitnessDelimiterSettings],
) -> Result<(), toml::ser::Error> {
self.out.push_str(
"# Witness delimiter regex pairs; each first capture group is the entry address.\n",
);
self.out.push_str("# Canonical entry-address capture: ");
self.out.push_str(WITNESS_ENTRY_ADDRESS_CAPTURE_REGEX);
self.out.push('\n');
for (index, delimiter) in delimiters.iter().enumerate() {
if index > 0 {
self.out.push('\n');
}
self.push_array_table("witness.delimiters");
self.push_bare_field("begin", &delimiter.begin)?;
self.push_bare_field("end", &delimiter.end)?;
}
Ok(())
}
fn push_bare_field<T: Serialize + ?Sized>(
&mut self, name: &str, value: &T,
) -> Result<(), toml::ser::Error> {
self.out.push_str(name);
self.out.push_str(" = ");
self.out.push_str(&Self::toml_value(value)?);
self.out.push('\n');
Ok(())
}
fn push_array_table(&mut self, name: &str) {
self.out.push_str("[[");
self.out.push_str(name);
self.out.push_str("]]\n");
}
fn push_upstreams(&mut self, upstreams: &UpstreamSettingsMap) -> Result<(), toml::ser::Error> {
for (index, (domain, upstream)) in upstreams.iter().enumerate() {
if index > 0 {
self.out.push('\n');
}
self.push_table(&format!("upstreams.{domain}"));
if index == 0 {
self.out
.push_str(
"# Git-backed upstream lake crystallized into a glacier under this entry domain.\n",
);
self.out.push_str(
"# Optional mist selects the imported portion from the upstream project.\n",
);
}
self.push_bare_field("git", &upstream.git)?;
if let Some(branch) = &upstream.branch {
self.push_bare_field("branch", branch)?;
}
if let Some(tag) = &upstream.tag {
self.push_bare_field("tag", tag)?;
}
if let Some(rev) = &upstream.rev {
self.push_bare_field("rev", rev)?;
}
if !is_default_project(&upstream.project) {
self.push_bare_field("project", &upstream.project)?;
}
if let Some(mist) = &upstream.mist {
self.push_bare_field("mist", mist)?;
}
}
Ok(())
}
fn toml_value<T: Serialize + ?Sized>(value: &T) -> Result<String, toml::ser::Error> {
Ok(toml::Value::try_from(value)?.to_string())
}
}
#[derive(Debug, Error)]
pub enum ConfigError {
#[error("failed to read config file {path}")]
Read {
path: PathBuf,
#[source]
source: std::io::Error,
},
#[error("failed to parse config file {path}: {source}")]
Parse {
path: PathBuf,
#[source]
source: toml::de::Error,
},
#[error("failed to render config file")]
Render(#[source] toml::ser::Error),
#[error("lake.ignore path must be relative to the lake root: {0}")]
LakeIgnorePath(PathBuf),
#[error("repo.members path must be relative to the config directory: {0}")]
RepoMemberPath(String),
#[error("link relation name must be a non-empty single-line metadata key: {0}")]
StructuralFieldName(String),
#[error("link relation name is reserved for Sirno metadata: {0}")]
ReservedStructuralField(String),
#[error("{field} at index {index} must not be empty")]
WitnessRegex {
field: &'static str,
index: usize,
},
#[error("{field} at index {index} contains an invalid regex")]
WitnessRegexSyntax {
field: &'static str,
index: usize,
#[source]
source: regex::Error,
},
#[error("{field} at index {index} must capture the entry address")]
WitnessRegexCapture {
field: &'static str,
index: usize,
},
#[error("{field} at index {index} must not match empty text")]
WitnessRegexEmptyMatch {
field: &'static str,
index: usize,
},
#[error("upstream `{0}` git source must not be empty")]
UpstreamGitSource(EntryAtom),
#[error("upstream `{0}` must configure exactly one of branch, tag, or rev")]
UpstreamRefSelector(EntryAtom),
#[error("upstream `{domain}` project path must be relative within the Git tree: {path}")]
UpstreamProjectPath {
domain: EntryAtom,
path: PathBuf,
},
#[error("charm.enabled repeats entry address `{0}`")]
DuplicateCharmEnabled(EntryAddress),
#[error("failed to create config file {path}")]
Create {
path: PathBuf,
#[source]
source: std::io::Error,
},
#[error("failed to write config file {path}")]
Write {
path: PathBuf,
#[source]
source: std::io::Error,
},
}
#[cfg(test)]
mod tests {
use super::*;
const TEST_WITNESS_BEGIN_REGEX: &str = "(?m)^BEGIN ([A-Za-z0-9_-]+)$";
const TEST_WITNESS_END_REGEX: &str = "(?m)^END ([A-Za-z0-9_-]+)$";
fn test_witness_syntax() -> WitnessSettings {
WitnessSettings {
delimiters: vec![WitnessDelimiterSettings::new(
TEST_WITNESS_BEGIN_REGEX,
TEST_WITNESS_END_REGEX,
)],
}
}
fn config_source(source: &str) -> String {
format!(
"{source}\n[witness]\n[[witness.delimiters]]\nbegin = '{begin}'\nend = '{end}'\n",
begin = TEST_WITNESS_BEGIN_REGEX,
end = TEST_WITNESS_END_REGEX,
)
}
fn parse_config(source: &str) -> SirnoConfig {
toml::from_str(&config_source(source)).unwrap()
}
fn assert_before(source: &str, before: &str, after: &str) {
assert!(source.find(before).unwrap() < source.find(after).unwrap());
}
#[test]
fn parses_minimal_config() {
let config = parse_config(
r#"
[lake]
path = "docs"
"#,
);
assert_eq!(config.lake.path, PathBuf::from("docs"));
assert!(config.upstreams.is_empty());
assert!(config.lake.ignore.is_empty());
assert_eq!(config.repo, None);
assert_eq!(config.witness, test_witness_syntax());
assert_eq!(config.check, CheckSettings::default());
assert!(config.check.render_enabled());
assert_eq!(config.tutorial, None);
assert_eq!(config.charm, CharmSettings::default());
}
#[test]
fn parses_charm_settings() {
let config = parse_config(
r#"
[lake]
path = "docs"
[charm]
enabled = ["build-spell", "format-spell"]
"#,
);
assert_eq!(
config.charm.enabled,
vec![
EntryAddress::new("build-spell").unwrap(),
EntryAddress::new("format-spell").unwrap()
]
);
}
#[test]
fn rejects_duplicate_enabled_charms() {
let error = SirnoConfig::from_source(
Path::new("Sirno.toml"),
&config_source(
r#"
[lake]
path = "docs"
[charm]
enabled = ["build-spell", "build-spell"]
"#,
),
)
.unwrap_err();
assert!(matches!(
error,
ConfigError::DuplicateCharmEnabled(id) if id == EntryAddress::new("build-spell").unwrap()
));
}
#[test]
fn rejects_anchor_settings() {
let error = SirnoConfig::from_source(
Path::new("Sirno.toml"),
r#"
[lake]
path = "docs"
[anchor]
path = ".sirno/anchor.toml"
"#,
)
.unwrap_err();
assert!(error.to_string().contains("unknown field"));
}
#[test]
fn parses_upstream_settings() {
let config = parse_config(
r#"
[lake]
path = "docs"
[upstreams.core]
git = "https://example.invalid/core.git"
branch = "main"
project = "packages/core"
mist = "public"
[upstreams.std]
git = "../std.git"
tag = "stable"
"#,
);
assert_eq!(
config.upstreams.get(&EntryAtom::new("core").unwrap()),
Some(&UpstreamSettings {
git: "https://example.invalid/core.git".to_owned(),
branch: Some("main".to_owned()),
tag: None,
rev: None,
project: PathBuf::from("packages/core"),
mist: Some(EntryAtom::new("public").unwrap()),
})
);
assert_eq!(
config.upstreams.get(&EntryAtom::new("std").unwrap()),
Some(&UpstreamSettings::tag("../std.git", "stable"))
);
}
#[test]
fn parses_check_settings() {
let config = parse_config(
r#"
[lake]
path = "docs"
[check]
render = false
"#,
);
assert_eq!(config.check, CheckSettings { render: Some(false) });
assert!(!config.check.render_enabled());
}
#[test]
fn omitted_check_flags_default_to_enabled() {
let config = parse_config(
r#"
[lake]
path = "docs"
[check]
"#,
);
assert_eq!(config.check, CheckSettings { render: None });
assert!(config.check.render_enabled());
}
#[test]
fn parses_tutorial_settings() {
let default_tutorial = parse_config(
r#"
[lake]
path = "docs"
[tutorial]
"#,
);
let selected_tutorial = parse_config(
r#"
[lake]
path = "docs"
[tutorial]
anchor_update_tide = false
anchor_bootstrap_tide = true
"#,
);
assert_eq!(default_tutorial.tutorial, Some(TutorialSettings::all()));
assert_eq!(
selected_tutorial.tutorial,
Some(TutorialSettings { anchor_update_tide: false, anchor_bootstrap_tide: true })
);
}
#[test]
fn parses_repo_members() {
let config = parse_config(
r#"
[lake]
path = "docs"
[repo]
members = ["src", "Cargo.toml", "crates/*/src"]
"#,
);
assert_eq!(
config.repo,
Some(RepoSettings {
members: vec![
RepoMember::new("src").unwrap(),
RepoMember::new("Cargo.toml").unwrap(),
RepoMember::new("crates/*/src").unwrap(),
],
})
);
}
#[test]
fn parses_witness_syntax_settings() {
let config: SirnoConfig = toml::from_str(
r#"
[lake]
path = "docs"
[witness]
[[witness.delimiters]]
begin = '(?m)^BEGIN ([A-Za-z0-9_-]+)$'
end = '(?m)^END ([A-Za-z0-9_-]+)$'
[[witness.delimiters]]
begin = '(?m)^START ([A-Za-z0-9_-]+)$'
end = '(?m)^STOP ([A-Za-z0-9_-]+)$'
"#,
)
.unwrap();
assert_eq!(
config.witness,
WitnessSettings {
delimiters: vec![
WitnessDelimiterSettings::new(
"(?m)^BEGIN ([A-Za-z0-9_-]+)$",
"(?m)^END ([A-Za-z0-9_-]+)$",
),
WitnessDelimiterSettings::new(
"(?m)^START ([A-Za-z0-9_-]+)$",
"(?m)^STOP ([A-Za-z0-9_-]+)$",
),
],
}
);
}
#[test]
fn parses_empty_witness_syntax_settings() {
let bare: SirnoConfig = toml::from_str(
r#"
[lake]
path = "docs"
[witness]
"#,
)
.unwrap();
let explicit: SirnoConfig = toml::from_str(
r#"
[lake]
path = "docs"
[witness]
delimiters = []
"#,
)
.unwrap();
assert!(bare.witness.delimiters.is_empty());
assert!(explicit.witness.delimiters.is_empty());
}
#[test]
fn parses_lake_ignore_settings() {
let config = parse_config(
r#"
[lake]
path = "docs"
ignore = [".obsidian", "drafts"]
"#,
);
assert_eq!(config.lake.path, PathBuf::from("docs"));
assert_eq!(config.lake.ignore, vec![PathBuf::from(".obsidian"), PathBuf::from("drafts")]);
}
#[test]
fn rejects_unknown_fields() {
let source = config_source(
r#"
[lake]
path = "docs"
extra = "no"
"#,
);
let error = toml::from_str::<SirnoConfig>(&source).unwrap_err();
assert!(error.to_string().contains("unknown field"));
}
#[test]
fn rejects_render_settings_in_project_config() {
let source = config_source(
r#"
[lake]
path = "docs"
[render.structural]
belongs = ["to"]
"#,
);
let error = toml::from_str::<SirnoConfig>(&source).unwrap_err();
assert!(error.to_string().contains("unknown field"));
}
#[test]
fn rejects_missing_witness_syntax() {
let error = toml::from_str::<SirnoConfig>(
r#"
[lake]
path = "docs"
"#,
)
.unwrap_err();
assert!(error.to_string().contains("missing field `witness`"));
}
#[test]
fn resolves_relative_paths_against_config_directory() {
let config = SirnoConfig::default_project();
let config_path = Path::new("/tmp/project/Sirno.toml");
assert_eq!(config.resolve_lake(config_path), PathBuf::from("/tmp/project/docs"));
}
#[test]
fn rejects_ignore_paths_outside_lake_root() {
let temp = tempfile::tempdir().unwrap();
let path = temp.path().join(CONFIG_FILE_NAME);
fs::write(
&path,
config_source(
r#"
[lake]
path = "docs"
ignore = ["../outside"]
"#,
),
)
.unwrap();
let error = SirnoConfig::from_file(&path).unwrap_err();
assert!(matches!(error, ConfigError::LakeIgnorePath(_)));
}
#[test]
fn rejects_repo_members_outside_config_root() {
let temp = tempfile::tempdir().unwrap();
let path = temp.path().join(CONFIG_FILE_NAME);
fs::write(
&path,
config_source(
r#"
[lake]
path = "docs"
[repo]
members = ["../outside"]
"#,
),
)
.unwrap();
let error = SirnoConfig::from_file(&path).unwrap_err();
assert!(matches!(error, ConfigError::RepoMemberPath(_)));
}
#[test]
fn rejects_invalid_upstream_ref_selectors_and_project_paths() {
let temp = tempfile::tempdir().unwrap();
let path = temp.path().join(CONFIG_FILE_NAME);
fs::write(
&path,
config_source(
r#"
[lake]
path = "docs"
[upstreams.core]
git = "../core.git"
branch = "main"
tag = "stable"
"#,
),
)
.unwrap();
let error = SirnoConfig::from_file(&path).unwrap_err();
assert!(
matches!(error, ConfigError::UpstreamRefSelector(domain) if domain.as_str() == "core")
);
fs::write(
&path,
config_source(
r#"
[lake]
path = "docs"
[upstreams.core]
git = "../core.git"
branch = "main"
project = "../core"
"#,
),
)
.unwrap();
let error = SirnoConfig::from_file(&path).unwrap_err();
assert!(
matches!(error, ConfigError::UpstreamProjectPath { domain, .. } if domain.as_str() == "core")
);
}
#[test]
fn rejects_empty_witness_regex() {
let temp = tempfile::tempdir().unwrap();
let path = temp.path().join(CONFIG_FILE_NAME);
fs::write(
&path,
r#"
[lake]
path = "docs"
[witness]
[[witness.delimiters]]
begin = ""
end = '(?m)^END ([A-Za-z0-9_-]+)$'
"#,
)
.unwrap();
let error = SirnoConfig::from_file(&path).unwrap_err();
assert!(matches!(
error,
ConfigError::WitnessRegex { field, index: 0 }
if field == "witness.delimiters.begin"
));
}
#[test]
fn rejects_invalid_witness_regex() {
let temp = tempfile::tempdir().unwrap();
let path = temp.path().join(CONFIG_FILE_NAME);
fs::write(
&path,
r#"
[lake]
path = "docs"
[witness]
[[witness.delimiters]]
begin = '('
end = '(?m)^END ([A-Za-z0-9_-]+)$'
"#,
)
.unwrap();
let error = SirnoConfig::from_file(&path).unwrap_err();
assert!(matches!(
error,
ConfigError::WitnessRegexSyntax { field, index: 0, .. }
if field == "witness.delimiters.begin"
));
}
#[test]
fn rejects_witness_regex_without_capture() {
let temp = tempfile::tempdir().unwrap();
let path = temp.path().join(CONFIG_FILE_NAME);
fs::write(
&path,
r#"
[lake]
path = "docs"
[witness]
[[witness.delimiters]]
begin = '(?m)^BEGIN$'
end = '(?m)^END ([A-Za-z0-9_-]+)$'
"#,
)
.unwrap();
let error = SirnoConfig::from_file(&path).unwrap_err();
assert!(matches!(
error,
ConfigError::WitnessRegexCapture { field, index: 0 }
if field == "witness.delimiters.begin"
));
}
#[test]
fn rejects_empty_matching_witness_regex() {
let temp = tempfile::tempdir().unwrap();
let path = temp.path().join(CONFIG_FILE_NAME);
fs::write(
&path,
r#"
[lake]
path = "docs"
[witness]
[[witness.delimiters]]
begin = '()'
end = '(?m)^END ([A-Za-z0-9_-]+)$'
"#,
)
.unwrap();
let error = SirnoConfig::from_file(&path).unwrap_err();
assert!(matches!(
error,
ConfigError::WitnessRegexEmptyMatch { field, index: 0 }
if field == "witness.delimiters.begin"
));
}
#[test]
fn validates_empty_witness_delimiter_list() {
let temp = tempfile::tempdir().unwrap();
let path = temp.path().join(CONFIG_FILE_NAME);
fs::write(
&path,
r#"
[lake]
path = "docs"
[witness]
delimiters = []
"#,
)
.unwrap();
let config = SirnoConfig::from_file(&path).unwrap();
assert!(config.witness.delimiters.is_empty());
}
#[test]
fn standard_witness_regexes_use_canonical_entry_address_capture() {
let syntax = WitnessSettings::standard();
for delimiter in syntax.delimiters {
assert!(delimiter.begin.contains(WITNESS_ENTRY_ADDRESS_CAPTURE_REGEX));
assert!(delimiter.end.contains(WITNESS_ENTRY_ADDRESS_CAPTURE_REGEX));
}
}
#[test]
fn standard_witness_regexes_accept_dotted_paths_and_reject_other_separators() {
let line_begin = Regex::new(STANDARD_LINE_WITNESS_BEGIN_REGEX).unwrap();
let markdown_begin = Regex::new(STANDARD_MARKDOWN_WITNESS_BEGIN_REGEX).unwrap();
assert!(line_begin.is_match("// sirno:witness:valid-entry:begin"));
assert!(line_begin.is_match("// sirno:witness:core.design:begin"));
assert!(!line_begin.is_match("// sirno:witness:bad,id:begin"));
assert!(!line_begin.is_match("// sirno:witness:bad\rid:begin"));
assert!(!line_begin.is_match("// sirno:witness:bad\nid:begin"));
assert!(markdown_begin.is_match("<!-- sirno:witness:valid-entry:begin -->"));
assert!(markdown_begin.is_match("<!-- sirno:witness:core.design:begin -->"));
assert!(!markdown_begin.is_match("<!-- sirno:witness:bad,id:begin -->"));
assert!(!markdown_begin.is_match("<!-- sirno:witness:bad\rid:begin -->"));
assert!(!markdown_begin.is_match("<!-- sirno:witness:bad\nid:begin -->"));
}
#[test]
fn writes_and_reads_config_without_overwrite() {
let temp = tempfile::tempdir().unwrap();
let path = temp.path().join(CONFIG_FILE_NAME);
let config = SirnoConfig::default_project();
config.write_new(&path).unwrap();
let read = SirnoConfig::from_file(&path).unwrap();
assert_eq!(read, config);
assert!(matches!(config.write_new(&path), Err(ConfigError::Create { .. })));
}
#[test]
fn default_project_writes_witness_syntax_and_omits_optional_tables() {
let source = SirnoConfig::default_project().to_toml().unwrap();
assert!(source.contains("[lake]"));
assert!(source.contains("[witness]"));
assert!(source.contains("[[witness.delimiters]]"));
assert!(source.contains("# Sirno Lake path"));
assert!(source.contains("# Witness delimiter regex pairs"));
assert!(source.contains(&format!(
"# Canonical entry-address capture: {WITNESS_ENTRY_ADDRESS_CAPTURE_REGEX}"
)));
assert!(!source.contains("# Opening witness delimiter regex."));
assert!(!source.contains("# Closing witness delimiter regex."));
assert!(!source.contains("[check]"));
assert!(!source.contains("# Require generated footers"));
assert!(!source.contains("structural-inhabitance"));
assert!(!source.contains("[tutorial]"));
assert!(!source.contains("[structural]"));
assert!(!source.contains("# Structural metadata field"));
assert!(!source.contains("[repo]"));
}
#[test]
fn rendered_config_keeps_selected_comments_and_structural_link_order() {
let mut upstream = UpstreamSettings::branch("https://example.invalid/core.git", "main");
upstream.project = PathBuf::from("packages/core");
upstream.mist = Some(EntryAtom::new("public").unwrap());
let upstreams = UpstreamSettingsMap::from([(EntryAtom::new("core").unwrap(), upstream)]);
let config = SirnoConfig {
lake: LakeSettings {
path: PathBuf::from("docs"),
ignore: vec![PathBuf::from(".obsidian")],
},
upstreams,
repo: Some(RepoSettings { members: vec![RepoMember::new("src").unwrap()] }),
witness: test_witness_syntax(),
check: CheckSettings { render: Some(false) },
tutorial: Some(TutorialSettings {
anchor_update_tide: true,
anchor_bootstrap_tide: false,
}),
charm: CharmSettings { enabled: vec![EntryAddress::new("format-spell").unwrap()] },
};
let source = config.to_toml().unwrap();
let read: SirnoConfig = toml::from_str(&source).unwrap();
assert_eq!(read, config);
assert!(source.contains("# Sirno Lake path"));
assert!(source.contains("# Paths in lake that Sirno skips"));
assert!(!source.contains("[anchor]"));
assert!(source.contains("[upstreams.core]"));
assert!(source.contains(
"# Git-backed upstream lake crystallized into a glacier under this entry domain."
));
assert!(
source.contains(
"# Optional mist selects the imported portion from the upstream project."
)
);
assert!(source.contains("git = \"https://example.invalid/core.git\""));
assert!(source.contains("branch = \"main\""));
assert!(source.contains("project = \"packages/core\""));
assert!(source.contains("mist = \"public\""));
assert!(source.contains("# Repository files, directories, or globs"));
assert!(source.contains("# Witness delimiter regex pairs"));
assert!(source.contains(&format!(
"# Canonical entry-address capture: {WITNESS_ENTRY_ADDRESS_CAPTURE_REGEX}"
)));
assert!(!source.contains("# Opening witness delimiter regex."));
assert!(!source.contains("# Closing witness delimiter regex."));
assert!(source.contains("# Require generated footers"));
assert!(source.contains("render = false"));
assert!(source.contains("[tutorial]"));
assert!(source.contains(
"# Presence of this table enables tutorial text for recoverable command failures."
));
assert!(source.contains("# Remove this table to keep CLI errors terse."));
assert!(source.contains(
"# Show tutorial text when anchor update is blocked by open tide workitems."
));
assert!(source.contains(
"# Include first-anchor bootstrap context in the anchor update tide tutorial."
));
assert!(source.contains("anchor_update_tide = true"));
assert!(source.contains("anchor_bootstrap_tide = false"));
assert!(source.contains("[charm]"));
assert!(
source
.contains("# Entry addresses whose charm manifests may resolve and invoke spells.")
);
assert!(source.contains("enabled = [\"format-spell\"]"));
assert!(!source.contains("[structural]"));
assert!(!source.contains("[render]"));
assert!(!source.contains("[render.structural]"));
assert_before(&source, "[upstreams.core]", "[repo]");
assert_before(&source, "[tutorial]", "[charm]");
}
#[test]
fn detects_missing_generated_comments() {
let config = SirnoConfig::default_project();
let source = config
.to_commented_toml()
.unwrap()
.replace("# Sirno Lake path, resolved relative to this config file.\n", "");
let missing = config.missing_comments_in(&source).unwrap();
assert_eq!(
missing,
vec!["Sirno Lake path, resolved relative to this config file.".to_owned()]
);
}
#[test]
fn rejects_anchor_table() {
let temp = tempfile::tempdir().unwrap();
let path = temp.path().join(CONFIG_FILE_NAME);
fs::write(
&path,
config_source(
r#"
[lake]
path = "docs"
[anchor]
path = ".sirno/anchor.toml"
"#,
),
)
.unwrap();
let error = SirnoConfig::from_file(&path).unwrap_err();
assert!(error.to_string().contains("unknown field"));
}
}