use std::env;
use std::ffi::OsString;
use std::fs;
use std::io::{self, ErrorKind, Write};
use std::path::{Path, PathBuf};
use std::process::{Command as ProcessCommand, ExitCode, ExitStatus};
use std::str::FromStr;
use std::time::{SystemTime, UNIX_EPOCH};
use clap::{CommandFactory, Parser, Subcommand, ValueEnum};
use clap_complete::{Shell, generate};
use indexmap::IndexMap;
use serde::ser::SerializeMap;
use sirno::{
CONFIG_FILE_NAME, CheckMode, ConfigError, Entry, EntryDirectory, EntryDirectoryCheckSettings,
EntryDirectoryError, EntryDirectoryReport, EntryDirectoryWritePolicy, EntryId, EntryIdError,
EntryMetadata, EntryParseError, EntryQuery, Eterator, FrostError, FrostLockStatus,
GenLinkDirectoryReport, GeneratedLinkBody, GeneratedLinkError, LockError, SirnoConfig,
SirnoFrost, SirnoLock, StructuralSettings, VagueEntryQuery, WitnessCheckSettings, WitnessError,
WitnessRecord,
};
use thiserror::Error;
const RG_PREPROCESSOR_ARGV0_PREFIX: &str = "sirno-rg-preprocess-";
#[derive(Debug, Parser)]
#[command(name = "sirno")]
#[command(about = "Manage Sirno design entries")]
struct Cli {
#[arg(short = 'C', long, global = true)]
config: Option<PathBuf>,
#[arg(short = 'L', long = "lake-path", global = true)]
lake_path: Option<PathBuf>,
#[command(subcommand)]
command: Command,
}
#[derive(Debug, Subcommand)]
enum Command {
#[command(flatten)]
TopLevelLake(LakeCommand),
#[command(flatten)]
TopLevelEntry(EntryCommand),
Lake {
#[command(subcommand)]
command: LakeCommand,
},
Entry {
#[command(subcommand)]
command: GroupedEntryCommand,
},
Frost {
#[command(subcommand)]
command: FrostCommand,
},
Util {
#[command(subcommand)]
command: UtilCommand,
},
}
#[derive(Debug, Subcommand)]
enum LakeCommand {
Init {
#[arg(long)]
mono: Option<PathBuf>,
#[arg(long)]
lake: Option<PathBuf>,
},
#[command(visible_alias = "mv")]
Move {
lake: PathBuf,
},
Check {
#[arg(long = "frost-path", conflicts_with = "lake_path")]
frost_path: Option<PathBuf>,
#[arg(short = 'm', long, value_enum)]
mode: Option<CliCheckMode>,
},
#[command(name = "gen-link")]
GenLink {
#[arg(short = 'n', long, visible_alias = "dry-run")]
dry: bool,
#[command(subcommand)]
command: Option<GenLinkCommand>,
},
#[command(visible_alias = "st")]
Status,
}
#[derive(Debug, Subcommand)]
enum EntryCommand {
New {
id: String,
#[arg(short = 'n', long)]
name: Option<String>,
#[arg(short = 'd', long)]
desc: String,
#[arg(long = "structural", value_name = "FIELD=ENTRY_ID")]
structural: Vec<CliStructuralPredicate>,
#[arg(short = 'b', long)]
body: Option<String>,
},
Rename {
old_id: String,
new_id: String,
},
Freeze {
id: String,
},
#[command(visible_alias = "unfreeze")]
Melt {
id: String,
},
#[command(visible_alias = "q")]
Query {
terms: Vec<String>,
#[arg(long = "exact-term")]
exact_terms: Vec<String>,
#[arg(short = 'x', long, value_name = "FIELD=ENTRY_ID")]
exact: Vec<CliStructuralPredicate>,
#[arg(short = 'f', long, value_name = "FIELDS")]
fields: Option<CliQueryFields>,
#[arg(short = 'o', long, value_enum)]
format: Option<CliQueryOutputFormat>,
},
Rg {
#[arg(long = "with-generated-footer")]
with_generated_footer: bool,
#[arg(required = true, trailing_var_arg = true, allow_hyphen_values = true)]
args: Vec<OsString>,
},
#[command(visible_aliases = ["w", "wit"])]
Witness {
id: String,
#[arg(short = 'f', long)]
full: bool,
},
}
#[derive(Debug, Subcommand)]
enum GroupedEntryCommand {
New {
id: String,
#[arg(short = 'n', long)]
name: Option<String>,
#[arg(short = 'd', long)]
desc: String,
#[arg(long = "structural", value_name = "FIELD=ENTRY_ID")]
structural: Vec<CliStructuralPredicate>,
#[arg(short = 'b', long)]
body: Option<String>,
},
#[command(visible_aliases = ["mv", "move"])]
Rename {
old_id: String,
new_id: String,
},
Freeze {
id: String,
},
#[command(visible_alias = "unfreeze")]
Melt {
id: String,
},
#[command(visible_alias = "q")]
Query {
terms: Vec<String>,
#[arg(long = "exact-term")]
exact_terms: Vec<String>,
#[arg(short = 'x', long, value_name = "FIELD=ENTRY_ID")]
exact: Vec<CliStructuralPredicate>,
#[arg(short = 'f', long, value_name = "FIELDS")]
fields: Option<CliQueryFields>,
#[arg(short = 'o', long, value_enum)]
format: Option<CliQueryOutputFormat>,
},
Rg {
#[arg(long = "with-generated-footer")]
with_generated_footer: bool,
#[arg(required = true, trailing_var_arg = true, allow_hyphen_values = true)]
args: Vec<OsString>,
},
#[command(visible_aliases = ["w", "wit"])]
Witness {
id: String,
#[arg(short = 'f', long)]
full: bool,
},
}
#[derive(Clone, Copy, Debug, ValueEnum)]
enum CliCheckMode {
Edit,
Review,
}
#[derive(Clone, Copy, Debug, ValueEnum)]
enum CliQueryOutputFormat {
Json,
Human,
}
#[derive(Clone, Debug, PartialEq, Eq)]
struct CliQueryFields {
fields: Vec<CliQueryField>,
}
impl Default for CliQueryFields {
fn default() -> Self {
Self { fields: vec![CliQueryField::Id, CliQueryField::Path, CliQueryField::Name] }
}
}
impl FromStr for CliQueryFields {
type Err = CliQueryFieldsParseError;
fn from_str(raw: &str) -> Result<Self, Self::Err> {
if raw.trim().is_empty() {
return Err(CliQueryFieldsParseError::Empty);
}
let mut fields = Vec::new();
for raw_field in raw.split(',') {
let field = raw_field.trim();
if field.is_empty() {
return Err(CliQueryFieldsParseError::EmptyField);
}
fields.push(field.parse()?);
}
Ok(Self { fields })
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum CliQueryField {
Id,
Name,
Path,
Desc,
}
impl FromStr for CliQueryField {
type Err = CliQueryFieldsParseError;
fn from_str(raw: &str) -> Result<Self, Self::Err> {
match raw {
| "id" => Ok(Self::Id),
| "name" => Ok(Self::Name),
| "path" => Ok(Self::Path),
| "desc" => Ok(Self::Desc),
| field => Err(CliQueryFieldsParseError::UnknownField(field.to_owned())),
}
}
}
impl CliQueryField {
fn label(self) -> &'static str {
match self {
| Self::Id => "id",
| Self::Name => "name",
| Self::Path => "path",
| Self::Desc => "desc",
}
}
}
#[derive(Debug, Error)]
enum CliQueryFieldsParseError {
#[error("query fields must include at least one field")]
Empty,
#[error("query fields contain an empty field")]
EmptyField,
#[error("unknown query field `{0}`; expected id, name, path, or desc")]
UnknownField(String),
}
#[derive(Clone, Debug, PartialEq, Eq)]
struct CliStructuralPredicate {
field: String,
target: EntryId,
}
impl FromStr for CliStructuralPredicate {
type Err = CliStructuralPredicateParseError;
fn from_str(raw: &str) -> Result<Self, Self::Err> {
let Some((field, target)) = raw.split_once('=') else {
return Err(CliStructuralPredicateParseError::MissingEquals);
};
if field.is_empty() {
return Err(CliStructuralPredicateParseError::EmptyField);
}
let target = EntryId::new(target)?;
Ok(Self { field: field.to_owned(), target })
}
}
#[derive(Debug, Error)]
enum CliStructuralPredicateParseError {
#[error("expected FIELD=ENTRY_ID")]
MissingEquals,
#[error("structural field name must not be empty")]
EmptyField,
#[error(transparent)]
EntryId(#[from] EntryIdError),
}
#[derive(Clone, Copy, Debug, ValueEnum)]
enum CliCompletionShell {
Bash,
Elvish,
Fish,
#[value(name = "powershell", alias = "power-shell")]
PowerShell,
Zsh,
}
#[derive(Debug, Subcommand)]
enum UtilCommand {
Completion {
#[arg(value_enum)]
shell: CliCompletionShell,
},
}
#[derive(Debug, Subcommand)]
enum FrostCommand {
Init {
#[arg(long = "frost-path")]
frost_path: Option<PathBuf>,
},
#[command(visible_alias = "mv")]
Move {
frost: PathBuf,
},
Commit,
#[command(visible_alias = "defrost")]
Checkout {
#[arg(required_unless_present = "latest", conflicts_with = "latest")]
version: Option<u64>,
#[arg(long, conflicts_with = "unsafe_mutable")]
latest: bool,
#[arg(long)]
unsafe_mutable: bool,
},
}
#[derive(Debug, Subcommand)]
enum GenLinkCommand {
Delete,
}
impl From<CliCheckMode> for CheckMode {
fn from(value: CliCheckMode) -> Self {
match value {
| CliCheckMode::Edit => CheckMode::Edit,
| CliCheckMode::Review => CheckMode::Review,
}
}
}
impl From<CliCompletionShell> for Shell {
fn from(value: CliCompletionShell) -> Self {
match value {
| CliCompletionShell::Bash => Shell::Bash,
| CliCompletionShell::Elvish => Shell::Elvish,
| CliCompletionShell::Fish => Shell::Fish,
| CliCompletionShell::PowerShell => Shell::PowerShell,
| CliCompletionShell::Zsh => Shell::Zsh,
}
}
}
fn main() -> ExitCode {
if is_rg_preprocessor_invocation() {
return match run_rg_preprocessor_from_env() {
| Ok(code) => code,
| Err(error) => {
eprintln!("sirno: {error}");
ExitCode::FAILURE
}
};
}
match Cli::parse().run() {
| Ok(code) => code,
| Err(error) => {
eprintln!("sirno: {error}");
ExitCode::FAILURE
}
}
}
impl Cli {
fn run(self) -> Result<ExitCode, CliError> {
let config_path = self.config.unwrap_or_else(default_config_path);
let lake_path = self.lake_path;
match self.command {
| Command::TopLevelLake(command) | Command::Lake { command } => {
command.run(&config_path, lake_path.as_deref())
}
| Command::TopLevelEntry(command) => command.run(&config_path, lake_path.as_deref()),
| Command::Entry { command } => {
EntryCommand::from(command).run(&config_path, lake_path.as_deref())
}
| Command::Frost { command } => command.run(&config_path, lake_path.as_deref()),
| Command::Util { command } => command.run(),
}
}
}
impl From<GroupedEntryCommand> for EntryCommand {
fn from(command: GroupedEntryCommand) -> Self {
match command {
| GroupedEntryCommand::New { id, name, desc, structural, body } => {
Self::New { id, name, desc, structural, body }
}
| GroupedEntryCommand::Rename { old_id, new_id } => Self::Rename { old_id, new_id },
| GroupedEntryCommand::Freeze { id } => Self::Freeze { id },
| GroupedEntryCommand::Melt { id } => Self::Melt { id },
| GroupedEntryCommand::Query { terms, exact_terms, exact, fields, format } => {
Self::Query { terms, exact_terms, exact, fields, format }
}
| GroupedEntryCommand::Rg { with_generated_footer, args } => {
Self::Rg { with_generated_footer, args }
}
| GroupedEntryCommand::Witness { id, full } => Self::Witness { id, full },
}
}
}
impl LakeCommand {
fn run(self, config_path: &Path, lake_path: Option<&Path>) -> Result<ExitCode, CliError> {
match self {
| LakeCommand::Init { mono, lake } => {
let mut config = SirnoConfig::new(
lake.or_else(|| lake_path.map(Path::to_path_buf))
.unwrap_or_else(default_lake_path),
);
if let Some(mono) = mono {
config = config.with_mono(mono);
}
let lake_path = config.resolve_lake(config_path);
config.write_new(config_path)?;
let paths = EntryDirectory::new(&lake_path).init()?;
println!(
"initialized {} with {} entries in {}",
config_path.display(),
paths.len(),
lake_path.display()
);
Ok(ExitCode::SUCCESS)
}
| LakeCommand::Move { lake } => {
let config = SirnoConfig::from_file(config_path)?;
let old_lake = config.resolve_lake(config_path);
let config = config.with_lake(lake);
config.validate_for_file(config_path)?;
let new_lake = config.resolve_lake(config_path);
move_configured_path_and_write_config(&old_lake, &new_lake, &config, config_path)?;
println!("moved lake {} to {}", old_lake.display(), new_lake.display());
Ok(ExitCode::SUCCESS)
}
| LakeCommand::Check { frost_path, mode } => {
if lake_path.is_some() && frost_path.is_some() {
return Err(CliError::LakePathWithFrostPath);
}
let mode = mode.unwrap_or(CliCheckMode::Review);
if lake_path.is_some() {
let (lake, settings) = resolve_lake_directory(lake_path, config_path)?;
let report =
EntryDirectory::new(lake).check_with_settings(mode.into(), &settings)?;
print_entry_directory_report(&report);
return if report.has_errors() {
Ok(ExitCode::FAILURE)
} else {
Ok(ExitCode::SUCCESS)
};
}
let Some(frost_path) = frost_path else {
let config = SirnoConfig::from_file(config_path)?;
let report = EntryDirectory::new(config.resolve_lake(config_path))
.check_with_settings(
mode.into(),
&entry_directory_check_settings(config_path, &config),
)?;
print_entry_directory_report(&report);
return if report.has_errors() {
Ok(ExitCode::FAILURE)
} else {
Ok(ExitCode::SUCCESS)
};
};
let frost = SirnoFrost::open(frost_path)?;
let report = frost.check_current(mode.into())?;
if report.is_clean() {
println!("ok: {}", frost.root().display());
return Ok(ExitCode::SUCCESS);
}
for diagnostic in report.diagnostics() {
println!("{}: {}", diagnostic.severity.label(), diagnostic.message());
}
if report.has_errors() { Ok(ExitCode::FAILURE) } else { Ok(ExitCode::SUCCESS) }
}
| LakeCommand::GenLink { command, dry } => match command {
| None => {
let (lake, mut settings) = resolve_lake_directory(lake_path, config_path)?;
settings.link = false;
settings.witness = None;
let directory = EntryDirectory::new(&lake);
let check = directory.check_with_settings(CheckMode::Review, &settings)?;
if check.has_errors() {
print_entry_directory_report(&check);
return Ok(ExitCode::FAILURE);
}
if dry {
let report = directory.check_generated_links_with_ignored_paths(
&settings.structural,
settings.ignore.clone(),
)?;
print_gen_link_report(&report);
return Ok(ExitCode::SUCCESS);
}
let report = directory.generate_links_with_ignored_paths(
&settings.structural,
settings.ignore.clone(),
)?;
print_gen_link_report(&report);
Ok(ExitCode::SUCCESS)
}
| Some(GenLinkCommand::Delete) => {
if dry {
return Err(CliError::DryWithGenLinkSubcommand);
}
let (lake, mut settings) = resolve_lake_directory(lake_path, config_path)?;
settings.witness = None;
let report = EntryDirectory::new(&lake)
.delete_generated_links_with_ignored_paths(settings.ignore)?;
print_gen_link_report(&report);
Ok(ExitCode::SUCCESS)
}
},
| LakeCommand::Status => {
let config = SirnoConfig::from_file(config_path)?;
let mono = config.resolve_mono(config_path);
let frost = config.resolve_frost(config_path);
let lock_path = SirnoLock::path_for_config(config_path);
let lock = if frost.is_some() {
SirnoLock::from_file_if_exists(&lock_path)?
} else {
None
};
let (lake, settings) = resolve_lake_directory(lake_path, config_path)?;
let report =
EntryDirectory::new(&lake).check_with_settings(CheckMode::Review, &settings)?;
print_status(
config_path,
mono.as_deref(),
frost.as_deref(),
lock.as_ref(),
&config,
&report,
);
if report.has_errors() { Ok(ExitCode::FAILURE) } else { Ok(ExitCode::SUCCESS) }
}
}
}
}
impl EntryCommand {
fn run(self, config_path: &Path, lake_path: Option<&Path>) -> Result<ExitCode, CliError> {
match self {
| EntryCommand::New { id, name, desc, structural, body } => {
let (lake, settings) = resolve_lake_directory(lake_path, config_path)?;
let id = EntryId::new(&id)?;
let mut metadata =
EntryMetadata::new(name.unwrap_or_else(|| title_name_from_id(&id)), desc)?;
for (field, targets) in
structural_targets_by_field(structural, &settings.structural)?
{
metadata.set_structural_targets(field, targets);
}
let entry = Entry::new(id, metadata, body.unwrap_or_default());
let path = EntryDirectory::new(&lake).create_entry(&entry)?;
println!("created {}", path.display());
Ok(ExitCode::SUCCESS)
}
| EntryCommand::Rename { old_id, new_id } => {
let (lake, settings) = resolve_lake_directory(lake_path, config_path)?;
let old_id = EntryId::new(&old_id)?;
let new_id = EntryId::new(&new_id)?;
let report =
EntryDirectory::new(&lake).rename_entry(&old_id, &new_id, &settings)?;
let mut changed_paths = report.changed_paths().to_vec();
if let Some(witness) = &settings.witness {
changed_paths.extend(witness.rename_entry_references(&old_id, &new_id)?);
}
changed_paths.sort();
changed_paths.dedup();
println!("renamed entry {old_id} to {new_id}");
println!("updated {} paths", changed_paths.len());
Ok(ExitCode::SUCCESS)
}
| EntryCommand::Freeze { id } => {
let (lake, _) = resolve_lake_directory(lake_path, config_path)?;
let id = EntryId::new(&id)?;
let path = EntryDirectory::new(&lake).freeze_entry(&id)?;
println!("froze entry {id} at {}", path.display());
Ok(ExitCode::SUCCESS)
}
| EntryCommand::Melt { id } => {
let (lake, _) = resolve_lake_directory(lake_path, config_path)?;
let id = EntryId::new(&id)?;
let path = EntryDirectory::new(&lake).melt_entry(&id)?;
println!("melted entry {id} at {}", path.display());
Ok(ExitCode::SUCCESS)
}
| EntryCommand::Query { terms, exact_terms, exact, fields, format } => {
let (lake, mut settings) = resolve_lake_directory(lake_path, config_path)?;
settings.link = false;
settings.witness = None;
let report =
EntryDirectory::new(&lake).check_with_settings(CheckMode::Edit, &settings)?;
if report.has_errors() {
print_entry_directory_report(&report);
return Ok(ExitCode::FAILURE);
}
let vague_query = VagueEntryQuery::new().with_text_terms(terms);
let exact_query = exact_query_from_predicates(
EntryQuery::new().with_text_terms(exact_terms),
exact,
&settings.structural,
)?;
let vague_matches = vague_query.select_entries(report.entries());
let matches = exact_query.select_entries(vague_matches);
let fields = fields.unwrap_or_default();
let format = format.unwrap_or(CliQueryOutputFormat::Json);
print_query_results(&report, &matches, &fields, format)?;
Ok(ExitCode::SUCCESS)
}
| EntryCommand::Rg { with_generated_footer, args } => {
run_rg_command(lake_path, config_path, with_generated_footer, args)
}
| EntryCommand::Witness { id, full } => {
run_witness_command(config_path, lake_path, &id, full)
}
}
}
}
impl FrostCommand {
fn run(
self, config_path: &std::path::Path, lake_path: Option<&Path>,
) -> Result<ExitCode, CliError> {
match self {
| FrostCommand::Init { frost_path } => {
let config = SirnoConfig::from_file(config_path)?;
let existing_frost = config.frost.as_ref().map(|settings| settings.path.clone());
let frost_path = frost_path
.or_else(|| existing_frost.clone())
.unwrap_or_else(default_frost_path);
if let Some(existing_frost) = existing_frost
&& existing_frost != frost_path
{
return Err(CliError::FrostAlreadyConfigured(existing_frost));
}
let needs_config_write = config.frost.is_none();
let config =
if needs_config_write { config.with_frost(frost_path) } else { config };
config.validate_for_file(config_path)?;
let frost_path =
config.resolve_frost(config_path).expect("frost path configured by init");
let frost = SirnoFrost::open(&frost_path)?;
let version = frost.current_snapshot()?;
if needs_config_write {
config.write(config_path)?;
}
SirnoLock::current(version).write(SirnoLock::path_for_config(config_path))?;
println!(
"initialized frost {} at version {}",
frost_path.display(),
version.version(),
);
Ok(ExitCode::SUCCESS)
}
| FrostCommand::Move { frost } => {
let config = SirnoConfig::from_file(config_path)?;
let Some(old_frost) = config.resolve_frost(config_path) else {
return Err(CliError::FrostNotConfigured);
};
let config = config.with_frost(frost);
config.validate_for_file(config_path)?;
let new_frost =
config.resolve_frost(config_path).expect("frost path configured by move");
move_configured_path_and_write_config(
&old_frost,
&new_frost,
&config,
config_path,
)?;
println!("moved frost {} to {}", old_frost.display(), new_frost.display());
Ok(ExitCode::SUCCESS)
}
| FrostCommand::Commit => {
let context = FrostContext::load(config_path, lake_path)?;
context.reject_immutable_checkout()?;
let mut frost = SirnoFrost::open(&context.frost_path)?;
let version =
frost.commit_entry_directory(&context.lake_path, &context.settings)?;
context.lake().set_writable(&context.settings)?;
SirnoLock::current(version).write(&context.lock_path)?;
println!(
"froze version {} from {}",
version.version(),
context.lake_path.display()
);
Ok(ExitCode::SUCCESS)
}
| FrostCommand::Checkout { version, latest, unsafe_mutable } => {
let context = FrostContext::load(config_path, lake_path)?;
let frost = SirnoFrost::open(&context.frost_path)?;
let snapshot = if latest {
frost.current_snapshot()?
} else {
frost.snapshot_for_version(frost_version(
version.expect("clap requires VERSION unless --latest is present"),
)?)?
};
if snapshot.version() == Eterator::EMPTY.version() {
return Err(CliError::InvalidFrostVersion(snapshot.version()));
}
let paths = frost.checkout_entry_directory(
snapshot,
&context.lake_path,
EntryDirectoryWritePolicy::ReplaceDirectory {
ignore: context.settings.ignore.clone(),
},
)?;
if latest || unsafe_mutable {
context.lake().set_writable(&context.settings)?;
} else {
context.lake().add_readonly_checkout_warnings(&paths)?;
context.lake().set_readonly(&context.settings)?;
}
if latest {
SirnoLock::current(snapshot).write(&context.lock_path)?;
} else {
SirnoLock::checked_out(snapshot, unsafe_mutable).write(&context.lock_path)?;
}
println!(
"checked out {}frost version {} into {} ({} entries, {})",
if latest { "latest " } else { "" },
snapshot.version(),
context.lake_path.display(),
paths.len(),
if latest {
"mutable"
} else if unsafe_mutable {
"unsafe mutable"
} else {
"immutable"
}
);
Ok(ExitCode::SUCCESS)
}
}
}
}
fn move_configured_path_and_write_config(
source: &Path, destination: &Path, config: &SirnoConfig, config_path: &Path,
) -> Result<(), CliError> {
let moved = move_configured_path(source, destination)?;
if let Err(config_error) = config.write(config_path) {
if moved && let Err(rollback) = fs::rename(destination, source) {
return Err(CliError::MoveConfigWriteRollback {
source_path: source.to_path_buf(),
destination_path: destination.to_path_buf(),
source: Box::new(config_error),
rollback,
});
}
return Err(CliError::Config(config_error));
}
Ok(())
}
fn move_configured_path(source: &Path, destination: &Path) -> Result<bool, CliError> {
if source == destination {
return Ok(false);
}
match fs::symlink_metadata(destination) {
| Ok(_) => return Err(CliError::MoveDestinationExists(destination.to_path_buf())),
| Err(source) if source.kind() == ErrorKind::NotFound => {}
| Err(source) => {
return Err(CliError::ReadMoveDestination { path: destination.to_path_buf(), source });
}
}
fs::rename(source, destination).map_err(|error| CliError::MovePath {
source_path: source.to_path_buf(),
destination_path: destination.to_path_buf(),
source: error,
})?;
Ok(true)
}
struct FrostContext {
frost_path: PathBuf,
lock_path: PathBuf,
settings: EntryDirectoryCheckSettings,
lake_path: PathBuf,
}
impl FrostContext {
fn load(config_path: &Path, lake_path: Option<&Path>) -> Result<Self, CliError> {
let config = SirnoConfig::from_file(config_path)?;
let Some(frost_path) = config.resolve_frost(config_path) else {
return Err(CliError::FrostNotConfigured);
};
Ok(Self {
frost_path,
lock_path: SirnoLock::path_for_config(config_path),
settings: entry_directory_check_settings(config_path, &config),
lake_path: resolve_lake_path(lake_path, config_path, &config),
})
}
fn lake(&self) -> EntryDirectory {
EntryDirectory::new(&self.lake_path)
}
fn reject_immutable_checkout(&self) -> Result<(), CliError> {
let Some(lock) = SirnoLock::from_file_if_exists(&self.lock_path)? else {
return Ok(());
};
if lock.frost.is_checked_out() && !lock.frost.is_unsafe_mutable_checkout() {
return Err(CliError::ImmutableFrostCheckout(lock.frost.version));
}
Ok(())
}
}
fn frost_version(version: u64) -> Result<Eterator, CliError> {
if version == Eterator::EMPTY.version() {
return Err(CliError::InvalidFrostVersion(version));
}
Ok(Eterator(version))
}
fn run_witness_command(
config_path: &Path, lake_path: Option<&Path>, raw_id: &str, full: bool,
) -> Result<ExitCode, CliError> {
let config = SirnoConfig::from_file(config_path)?;
let id = EntryId::new(raw_id)?;
let lake = resolve_lake_path(lake_path, config_path, &config);
if !EntryDirectory::new(&lake).entry_exists(&id)? {
return Err(CliError::MissingWitnessEntry(id));
}
let Some(settings) = witness_check_settings(config_path, &config) else {
return Err(CliError::RepoMembersNotConfigured);
};
let index = settings.scan()?;
let records = index.records_for(&id);
if records.is_empty() {
println!("no witness found for {id}");
return Ok(ExitCode::FAILURE);
}
print_witness_records(records, full);
Ok(ExitCode::SUCCESS)
}
fn print_witness_records(records: &[WitnessRecord], full: bool) {
print!("{}", format_witness_records(records, full));
}
fn run_rg_command(
lake_path: Option<&Path>, config_path: &Path, with_generated_footer: bool, args: Vec<OsString>,
) -> Result<ExitCode, CliError> {
if !with_generated_footer && rg_args_include_preprocessor(&args) {
return Err(CliError::RgPreprocessorConflict);
}
let lake = resolve_lake_path_for_rg(lake_path, config_path)?;
let preprocessor =
if with_generated_footer { None } else { Some(RgPreprocessorLink::create()?) };
let mut command = ProcessCommand::new("rg");
if let Some(preprocessor) = &preprocessor {
command.arg("--pre").arg(preprocessor.path()).arg("--pre-glob").arg("*.md");
}
let status = command.args(args).arg(lake).status().map_err(CliError::RunRg)?;
Ok(exit_code_from_status(status))
}
fn rg_args_include_preprocessor(args: &[OsString]) -> bool {
args.iter()
.filter_map(|arg| arg.to_str())
.any(|arg| arg == "--pre" || arg.starts_with("--pre="))
}
fn resolve_lake_path_for_rg(
lake_path: Option<&Path>, config_path: &Path,
) -> Result<PathBuf, CliError> {
if let Some(lake_path) = lake_path {
return Ok(lake_path.to_path_buf());
}
let config = SirnoConfig::from_file(config_path)?;
Ok(config.resolve_lake(config_path))
}
fn exit_code_from_status(status: ExitStatus) -> ExitCode {
if let Some(code) = status.code().and_then(|code| u8::try_from(code).ok()) {
return ExitCode::from(code);
}
ExitCode::FAILURE
}
fn is_rg_preprocessor_invocation() -> bool {
env::args_os()
.next()
.and_then(|arg| PathBuf::from(arg).file_name().map(|name| name.to_os_string()))
.is_some_and(|name| name.to_string_lossy().starts_with(RG_PREPROCESSOR_ARGV0_PREFIX))
}
fn run_rg_preprocessor_from_env() -> Result<ExitCode, CliError> {
let mut args = env::args_os().skip(1);
let Some(path) = args.next() else {
return Err(CliError::RgPreprocessorArgumentCount);
};
if args.next().is_some() {
return Err(CliError::RgPreprocessorArgumentCount);
}
run_rg_preprocessor(&PathBuf::from(path))
}
fn run_rg_preprocessor(path: &Path) -> Result<ExitCode, CliError> {
let body = fs::read_to_string(path)
.map_err(|source| CliError::ReadRgPreprocessorInput { path: path.to_path_buf(), source })?;
let masked = GeneratedLinkBody::new(&body).mask()?;
io::stdout().write_all(masked.as_bytes()).map_err(CliError::WriteRgPreprocessorOutput)?;
Ok(ExitCode::SUCCESS)
}
#[derive(Debug)]
struct RgPreprocessorLink {
path: PathBuf,
}
impl RgPreprocessorLink {
fn create() -> Result<Self, CliError> {
let current_exe = env::current_exe().map_err(CliError::LocateCurrentExe)?;
let mut path = env::temp_dir();
path.push(format!(
"{RG_PREPROCESSOR_ARGV0_PREFIX}{}-{}",
std::process::id(),
current_time_nanos()
));
#[cfg(not(unix))]
if let Some(extension) = current_exe.extension() {
path.set_extension(extension);
}
create_rg_preprocessor_invoker(¤t_exe, &path).map_err(|source| {
CliError::CreateRgPreprocessorInvoker { path: path.clone(), source }
})?;
Ok(Self { path })
}
fn path(&self) -> &Path {
&self.path
}
}
impl Drop for RgPreprocessorLink {
fn drop(&mut self) {
let _ = fs::remove_file(&self.path);
}
}
fn current_time_nanos() -> u128 {
SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_nanos()
}
#[cfg(unix)]
fn create_rg_preprocessor_invoker(current_exe: &Path, path: &Path) -> io::Result<()> {
std::os::unix::fs::symlink(current_exe, path)
}
#[cfg(not(unix))]
fn create_rg_preprocessor_invoker(current_exe: &Path, path: &Path) -> io::Result<()> {
fs::copy(current_exe, path).map(|_| ())
}
fn format_witness_records(records: &[WitnessRecord], full: bool) -> String {
let mut out = String::new();
for (index, record) in records.iter().enumerate() {
if full && index > 0 {
out.push_str("---\n\n");
}
out.push_str(&format_witness_record(record, full));
}
out
}
fn format_witness_record(record: &WitnessRecord, full: bool) -> String {
let range = format_witness_summary(record);
if !full {
let marker =
record.body.lines().next().map(str::to_owned).unwrap_or_else(|| record.marker.clone());
return format!("{range}\t{marker}\n");
}
let mut out = format!("{range}\n\n");
out.push_str(&record.body);
if !record.body.ends_with('\n') {
out.push('\n');
}
out.push('\n');
out
}
fn format_witness_summary(record: &WitnessRecord) -> String {
format!(
"{}:{}:{}-{} :: {}:{}-{}",
record.path.display(),
record.opening.start_line,
record.opening.start_column,
record.opening.end_column,
record.closing.start_line,
record.closing.start_column,
record.closing.end_column
)
}
impl UtilCommand {
fn run(self) -> Result<ExitCode, CliError> {
match self {
| UtilCommand::Completion { shell } => {
let shell = Shell::from(shell);
let mut command = Cli::command();
let mut stdout = std::io::stdout();
generate(shell, &mut command, "sirno", &mut stdout);
Ok(ExitCode::SUCCESS)
}
}
}
}
fn default_config_path() -> PathBuf {
PathBuf::from(CONFIG_FILE_NAME)
}
fn default_lake_path() -> PathBuf {
PathBuf::from("docs")
}
fn default_frost_path() -> PathBuf {
PathBuf::from("sirno-frost")
}
fn explicit_lake_check_settings(
config_path: &std::path::Path,
) -> Result<EntryDirectoryCheckSettings, CliError> {
if config_path.exists() {
let config = SirnoConfig::from_file(config_path)?;
Ok(entry_directory_check_settings(config_path, &config))
} else {
Ok(EntryDirectoryCheckSettings::default())
}
}
fn entry_directory_check_settings(
config_path: &Path, config: &SirnoConfig,
) -> EntryDirectoryCheckSettings {
EntryDirectoryCheckSettings {
link: config.check.link,
structural: config.structural.clone(),
ignore: config.lake.ignore.clone(),
witness: witness_check_settings(config_path, config),
}
}
fn witness_check_settings(
config_path: &Path, config: &SirnoConfig,
) -> Option<WitnessCheckSettings> {
let repo = config.repo.as_ref()?;
if repo.members.is_empty() {
return None;
}
Some(WitnessCheckSettings::new(
config_path.parent().unwrap_or_else(|| Path::new(".")),
repo.members.clone(),
config.witness.clone(),
))
}
fn resolve_lake_path(
lake_path: Option<&Path>, config_path: &Path, config: &SirnoConfig,
) -> PathBuf {
lake_path.map(Path::to_path_buf).unwrap_or_else(|| config.resolve_lake(config_path))
}
fn resolve_lake_directory(
lake_path: Option<&Path>, config_path: &std::path::Path,
) -> Result<(PathBuf, EntryDirectoryCheckSettings), CliError> {
if let Some(lake_path) = lake_path {
return Ok((lake_path.to_path_buf(), explicit_lake_check_settings(config_path)?));
}
let config = SirnoConfig::from_file(config_path)?;
Ok((config.resolve_lake(config_path), entry_directory_check_settings(config_path, &config)))
}
fn exact_query_from_predicates(
mut query: EntryQuery, predicates: Vec<CliStructuralPredicate>, structural: &StructuralSettings,
) -> Result<EntryQuery, CliError> {
for (field, targets) in structural_targets_by_field(predicates, structural)? {
query = query.with_structural_targets(field, targets);
}
Ok(query)
}
fn structural_targets_by_field(
predicates: Vec<CliStructuralPredicate>, structural: &StructuralSettings,
) -> Result<IndexMap<String, Vec<EntryId>>, CliError> {
let mut targets_by_field = IndexMap::<String, Vec<EntryId>>::new();
for predicate in predicates {
if !structural.contains_field(&predicate.field) {
return Err(CliError::UnconfiguredStructuralField(predicate.field));
}
targets_by_field.entry(predicate.field).or_default().push(predicate.target);
}
Ok(targets_by_field)
}
fn title_name_from_id(id: &EntryId) -> String {
id.as_str()
.split('-')
.map(|segment| {
let mut chars = segment.chars();
let Some(first) = chars.next() else {
return String::new();
};
let mut word = first.to_uppercase().to_string();
word.push_str(chars.as_str());
word
})
.collect::<Vec<_>>()
.join(" ")
}
fn print_status(
config_path: &std::path::Path, mono: Option<&std::path::Path>, frost: Option<&std::path::Path>,
lock: Option<&SirnoLock>, config: &SirnoConfig, report: &EntryDirectoryReport,
) {
println!("config: {}", config_path.display());
if let Some(mono) = mono {
println!("mono: {}", mono.display());
} else {
println!("mono: (not configured)");
}
println!("lake: {}", report.root().display());
if let Some(frost) = frost {
println!("frost: {}", frost.display());
println!("frost-state: {}", frost_state_label(lock));
} else {
println!("frost: (not configured)");
}
println!("entries: {}", report.entries().len());
println!("checks:");
println!(" link: {}", config.check.link);
println!("structural:");
for (field, settings) in config.structural.fields() {
println!(" {field}.link: {}", settings.link);
}
if report.has_errors() {
println!("check: failed");
print_entry_directory_report(report);
} else {
println!("check: ok");
}
}
fn frost_state_label(lock: Option<&SirnoLock>) -> String {
let Some(lock) = lock else {
return "(unlocked)".to_owned();
};
match lock.frost.status {
| FrostLockStatus::Current => {
format!(
"current version {} (generation {}, mutable)",
lock.frost.version, lock.frost.generation
)
}
| FrostLockStatus::CheckedOut if lock.frost.mutable => {
format!(
"checked-out version {} (generation {}, unsafe mutable)",
lock.frost.version, lock.frost.generation
)
}
| FrostLockStatus::CheckedOut => {
format!(
"checked-out version {} (generation {}, immutable)",
lock.frost.version, lock.frost.generation
)
}
}
}
fn print_gen_link_report(report: &GenLinkDirectoryReport) {
println!(
"{}",
format_gen_link_report(report.root(), report.entry_count(), report.changed_paths())
);
}
fn format_gen_link_report(root: &Path, entry_count: usize, changed_paths: &[PathBuf]) -> String {
if changed_paths.is_empty() {
return format!("No changes in {}", root.display());
}
let mut report = format!("Changes in {}:", root.display());
for path in changed_paths {
report.push_str("\n- ");
report.push_str(&path.display().to_string());
}
report.push_str("\nTotal changes: ");
report.push_str(&changed_paths.len().to_string());
report.push('/');
report.push_str(&entry_count.to_string());
report
}
fn print_query_results(
report: &EntryDirectoryReport, entries: &[&Entry], fields: &CliQueryFields,
format: CliQueryOutputFormat,
) -> Result<(), CliError> {
let rows = query_result_rows(report, entries, fields)?;
match format {
| CliQueryOutputFormat::Json => {
println!("{}", format_query_json(fields, &rows)?);
}
| CliQueryOutputFormat::Human => {
print!("{}", format_query_table(fields, &rows));
}
}
Ok(())
}
fn query_result_rows(
report: &EntryDirectoryReport, entries: &[&Entry], fields: &CliQueryFields,
) -> Result<Vec<Vec<String>>, CliError> {
entries
.iter()
.map(|entry| {
fields
.fields
.iter()
.map(|field| format_query_field(report, entry, *field))
.collect::<Result<Vec<_>, _>>()
})
.collect()
}
fn format_query_field(
report: &EntryDirectoryReport, entry: &Entry, field: CliQueryField,
) -> Result<String, CliError> {
match field {
| CliQueryField::Id => Ok(entry.id.to_string()),
| CliQueryField::Name => Ok(entry.metadata.name.clone()),
| CliQueryField::Path => {
let path = report
.entry_path(&entry.id)
.ok_or_else(|| EntryDirectoryError::MissingEntryPath(entry.id.clone()))?;
Ok(path.display().to_string())
}
| CliQueryField::Desc => Ok(entry.metadata.desc.clone()),
}
}
fn format_query_json(fields: &CliQueryFields, rows: &[Vec<String>]) -> Result<String, CliError> {
let records = rows.iter().map(|row| QueryJsonRecord { fields, row }).collect::<Vec<_>>();
Ok(serde_json::to_string_pretty(&records)?)
}
struct QueryJsonRecord<'a> {
fields: &'a CliQueryFields,
row: &'a [String],
}
impl serde::Serialize for QueryJsonRecord<'_> {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
let mut map = serializer.serialize_map(Some(self.fields.fields.len()))?;
for (field, value) in self.fields.fields.iter().zip(self.row) {
map.serialize_entry(field.label(), value)?;
}
map.end()
}
}
fn format_query_table(fields: &CliQueryFields, rows: &[Vec<String>]) -> String {
let headers = fields.fields.iter().map(|field| field.label()).collect::<Vec<_>>();
let mut widths = headers.iter().map(|header| cell_width(header)).collect::<Vec<_>>();
for row in rows {
for (index, cell) in row.iter().enumerate() {
widths[index] = widths[index].max(cell_width(cell));
}
}
let mut table = String::new();
push_query_table_row(&mut table, headers.iter().copied(), &widths);
push_query_table_separator(&mut table, &widths);
for row in rows {
push_query_table_row(&mut table, row.iter().map(String::as_str), &widths);
}
table
}
fn push_query_table_row<'a>(
table: &mut String, cells: impl IntoIterator<Item = &'a str>, widths: &[usize],
) {
table.push('|');
for (cell, width) in cells.into_iter().zip(widths) {
table.push(' ');
table.push_str(cell);
table.push_str(&" ".repeat(width.saturating_sub(cell_width(cell))));
table.push_str(" |");
}
table.push('\n');
}
fn push_query_table_separator(table: &mut String, widths: &[usize]) {
table.push('|');
for width in widths {
table.push(' ');
table.push_str(&"-".repeat(*width));
table.push_str(" |");
}
table.push('\n');
}
fn cell_width(cell: &str) -> usize {
cell.chars().count()
}
fn print_entry_directory_report(report: &EntryDirectoryReport) {
if report.is_clean() {
println!("ok: {}", report.root().display());
return;
}
for diagnostic in report.file_diagnostics() {
println!(
"{}: {}: {}",
diagnostic.severity.label(),
diagnostic.path.display(),
diagnostic.message
);
}
for diagnostic in report.structural_report().diagnostics() {
if let Some(path) = report.entry_path(&diagnostic.entry) {
println!(
"{}: {}: {}",
diagnostic.severity.label(),
path.display(),
diagnostic.message()
);
} else {
println!("{}: {}", diagnostic.severity.label(), diagnostic.message());
}
}
}
#[derive(Debug, Error)]
enum CliError {
#[error("frost is already configured at {0}")]
FrostAlreadyConfigured(PathBuf),
#[error("frost is not configured; run `sirno frost init` first")]
FrostNotConfigured,
#[error("frost version {0} is checked out immutably; use checkout --unsafe-mutable first")]
ImmutableFrostCheckout(u64),
#[error("frost version {0} is not a check-outable snapshot")]
InvalidFrostVersion(u64),
#[error("move destination already exists: {0}")]
MoveDestinationExists(PathBuf),
#[error("failed to inspect move destination {path}")]
ReadMoveDestination {
path: PathBuf,
#[source]
source: std::io::Error,
},
#[error("failed to move {source_path} to {destination_path}")]
MovePath {
source_path: PathBuf,
destination_path: PathBuf,
#[source]
source: std::io::Error,
},
#[error(
"failed to write config after moving {source_path} to {destination_path}; rollback failed: {rollback}"
)]
MoveConfigWriteRollback {
source_path: PathBuf,
destination_path: PathBuf,
#[source]
source: Box<ConfigError>,
rollback: std::io::Error,
},
#[error("repo members are not configured; add [repo].members to Sirno.toml")]
RepoMembersNotConfigured,
#[error("entry `{0}` does not exist")]
MissingWitnessEntry(EntryId),
#[error("`--lake-path` cannot be used with `check --frost-path`")]
LakePathWithFrostPath,
#[error("`--dry` only applies to `sirno gen-link` without a subcommand")]
DryWithGenLinkSubcommand,
#[error("structural field `{0}` is not configured; add it under [structural] in Sirno.toml")]
UnconfiguredStructuralField(String),
#[error(
"generated-footer filtering cannot be combined with `rg --pre`; use `--with-generated-footer`"
)]
RgPreprocessorConflict,
#[error("rg generated-footer preprocessor expects one path argument")]
RgPreprocessorArgumentCount,
#[error("failed to locate current executable for rg preprocessor")]
LocateCurrentExe(#[source] std::io::Error),
#[error("failed to create rg preprocessor invoker at {path}")]
CreateRgPreprocessorInvoker {
path: PathBuf,
#[source]
source: std::io::Error,
},
#[error("failed to read rg preprocessor input {path}")]
ReadRgPreprocessorInput {
path: PathBuf,
#[source]
source: std::io::Error,
},
#[error("failed to write rg preprocessor output")]
WriteRgPreprocessorOutput(#[source] std::io::Error),
#[error(transparent)]
Config(#[from] ConfigError),
#[error(transparent)]
Lock(#[from] LockError),
#[error(transparent)]
Frost(#[from] FrostError),
#[error(transparent)]
Witness(#[from] WitnessError),
#[error(transparent)]
EntryDirectory(#[from] EntryDirectoryError),
#[error(transparent)]
EntryId(#[from] EntryIdError),
#[error(transparent)]
EntryParse(#[from] EntryParseError),
#[error(transparent)]
GeneratedLink(#[from] GeneratedLinkError),
#[error("failed to run rg")]
RunRg(#[source] std::io::Error),
#[error(transparent)]
Json(#[from] serde_json::Error),
}
#[cfg(test)]
mod tests {
use std::ffi::OsString;
use std::fs;
use std::path::{Path, PathBuf};
use clap::Parser;
use sirno::{
CONFIG_FILE_NAME, Entry, EntryId, EntryMetadata, EntryQuery, Eterator, FrostLockStatus,
FrostSettings, LOCK_FILE_NAME, RepoMember, RepoSettings, SirnoConfig, SirnoFrost,
SirnoLock, StructuralFieldSettings, StructuralSettings, WitnessRecord, WitnessSpan,
};
use crate::{
Cli, CliCheckMode, CliError, CliQueryField, CliQueryFields, CliQueryOutputFormat,
CliStructuralPredicate, Command, EntryCommand, FrostCommand, GroupedEntryCommand,
LakeCommand, exact_query_from_predicates, format_gen_link_report, format_query_json,
format_query_table, format_witness_record, format_witness_records,
rg_args_include_preprocessor,
};
fn assert_before(source: &str, before: &str, after: &str) {
assert!(source.find(before).unwrap() < source.find(after).unwrap());
}
#[test]
fn init_does_not_accept_frost_path() {
let error =
Cli::try_parse_from(["sirno", "init", "--frost-path", "sirno-frost"]).unwrap_err();
assert!(error.to_string().contains("unexpected argument"));
}
#[test]
fn init_uses_global_lake_path() {
let temp = tempfile::tempdir().unwrap();
let config_path = temp.path().join(CONFIG_FILE_NAME);
let docs = temp.path().join("sirno-docs");
Cli::parse_from([
"sirno",
"--config",
config_path.to_str().unwrap(),
"--lake-path",
"sirno-docs",
"init",
])
.run()
.unwrap();
let config = SirnoConfig::from_file(&config_path).unwrap();
assert_eq!(config.lake.path, PathBuf::from("sirno-docs"));
assert!(docs.join("concept.md").exists());
}
#[test]
fn short_config_matches_global_config() {
let cli = Cli::parse_from(["sirno", "-C", "Sirno.alt.toml", "status"]);
assert_eq!(cli.config, Some(PathBuf::from("Sirno.alt.toml")));
assert!(matches!(cli.command, Command::TopLevelLake(LakeCommand::Status)));
}
#[test]
fn short_lake_path_matches_global_lake_path() {
let cli = Cli::parse_from(["sirno", "-L", "scratch-docs", "status"]);
assert_eq!(cli.lake_path.as_deref(), Some(Path::new("scratch-docs")));
assert!(matches!(cli.command, Command::TopLevelLake(LakeCommand::Status)));
}
#[test]
fn frost_init_accepts_frost_path() {
let cli = Cli::parse_from(["sirno", "frost", "init", "--frost-path", "sirno-frost"]);
assert!(matches!(
cli.command,
Command::Frost { command: FrostCommand::Init { frost_path: Some(_) } }
));
}
#[test]
fn frost_init_rejects_old_frost_flag() {
let error =
Cli::try_parse_from(["sirno", "frost", "init", "--frost", "sirno-frost"]).unwrap_err();
assert_eq!(error.kind(), clap::error::ErrorKind::UnknownArgument);
}
#[test]
fn frost_init_creates_empty_version_zero_store() {
let temp = tempfile::tempdir().unwrap();
let config_path = temp.path().join(CONFIG_FILE_NAME);
let docs = temp.path().join("docs");
let frost_path = temp.path().join("sirno-frost");
SirnoConfig::new("docs").write_new(&config_path).unwrap();
fs::create_dir(&docs).unwrap();
fs::write(
docs.join("alpha.md"),
"\
---
name: Alpha
desc: Alpha entry.
---
Body.
",
)
.unwrap();
Cli::parse_from(["sirno", "--config", config_path.to_str().unwrap(), "frost", "init"])
.run()
.unwrap();
let config = SirnoConfig::from_file(&config_path).unwrap();
let lock = SirnoLock::from_file(temp.path().join(LOCK_FILE_NAME)).unwrap();
let frost = SirnoFrost::open(&frost_path).unwrap();
let mut frost_paths = fs::read_dir(&frost_path)
.unwrap()
.map(|entry| entry.unwrap().file_name())
.collect::<Vec<_>>();
frost_paths.sort();
assert_eq!(config.frost, Some(FrostSettings { path: PathBuf::from("sirno-frost") }));
assert_eq!(lock.frost.status, FrostLockStatus::Current);
assert_eq!(lock.frost.version, Eterator::EMPTY.version());
assert_eq!(frost.current_version().unwrap(), Eterator::EMPTY);
assert!(frost.read_all_entries().unwrap().is_empty());
assert_eq!(frost_paths, [OsString::from("Eter.lock.toml")]);
}
#[test]
fn frost_checkout_latest_writes_mutable_current_lake() {
let temp = tempfile::tempdir().unwrap();
let config_path = temp.path().join(CONFIG_FILE_NAME);
let docs = temp.path().join("docs");
SirnoConfig::new("docs").with_frost("sirno-frost").write_new(&config_path).unwrap();
fs::create_dir(&docs).unwrap();
fs::write(
docs.join("alpha.md"),
"\
---
name: Alpha
desc: Alpha entry.
---
Body.
",
)
.unwrap();
Cli::parse_from(["sirno", "--config", config_path.to_str().unwrap(), "frost", "commit"])
.run()
.unwrap();
Cli::parse_from([
"sirno",
"--config",
config_path.to_str().unwrap(),
"frost",
"checkout",
"1",
])
.run()
.unwrap();
assert!(fs::metadata(docs.join("alpha.md")).unwrap().permissions().readonly());
Cli::parse_from([
"sirno",
"--config",
config_path.to_str().unwrap(),
"frost",
"checkout",
"--latest",
])
.run()
.unwrap();
let lock = SirnoLock::from_file(temp.path().join(LOCK_FILE_NAME)).unwrap();
let source = fs::read_to_string(docs.join("alpha.md")).unwrap();
assert_eq!(lock.frost.status, FrostLockStatus::Current);
assert_eq!(lock.frost.version, 1);
assert!(!lock.frost.mutable);
assert!(!source.contains("read-only Sirno Frost checkout"));
assert!(!fs::metadata(&docs).unwrap().permissions().readonly());
assert!(!fs::metadata(docs.join("alpha.md")).unwrap().permissions().readonly());
}
#[test]
fn move_accepts_lake_path() {
let cli = Cli::parse_from(["sirno", "move", "sirno-docs"]);
assert!(matches!(
cli.command,
Command::TopLevelLake(LakeCommand::Move { lake }) if lake == Path::new("sirno-docs")
));
}
#[test]
fn mv_alias_accepts_lake_path() {
let cli = Cli::parse_from(["sirno", "mv", "sirno-docs"]);
assert!(matches!(
cli.command,
Command::TopLevelLake(LakeCommand::Move { lake }) if lake == Path::new("sirno-docs")
));
}
#[test]
fn lake_move_accepts_mv_alias() {
let cli = Cli::parse_from(["sirno", "lake", "mv", "sirno-docs"]);
assert!(matches!(
cli.command,
Command::Lake { command: LakeCommand::Move { lake } }
if lake == Path::new("sirno-docs")
));
}
#[test]
fn frost_move_accepts_frost_path() {
let cli = Cli::parse_from(["sirno", "frost", "move", "sirno-frost-2"]);
assert!(matches!(
cli.command,
Command::Frost { command: FrostCommand::Move { frost } }
if frost == Path::new("sirno-frost-2")
));
}
#[test]
fn frost_mv_alias_accepts_frost_path() {
let cli = Cli::parse_from(["sirno", "frost", "mv", "sirno-frost-2"]);
assert!(matches!(
cli.command,
Command::Frost { command: FrostCommand::Move { frost } }
if frost == Path::new("sirno-frost-2")
));
}
#[test]
fn frost_checkout_accepts_unsafe_mutable_flag() {
let cli = Cli::parse_from(["sirno", "frost", "checkout", "3", "--unsafe-mutable"]);
assert!(matches!(
cli.command,
Command::Frost {
command: FrostCommand::Checkout {
version: Some(3),
latest: false,
unsafe_mutable: true
}
}
));
}
#[test]
fn frost_checkout_accepts_latest_flag() {
let cli = Cli::parse_from(["sirno", "frost", "checkout", "--latest"]);
assert!(matches!(
cli.command,
Command::Frost {
command: FrostCommand::Checkout {
version: None,
latest: true,
unsafe_mutable: false
}
}
));
}
#[test]
fn frost_defrost_alias_accepts_latest_flag() {
let cli = Cli::parse_from(["sirno", "frost", "defrost", "--latest"]);
assert!(matches!(
cli.command,
Command::Frost {
command: FrostCommand::Checkout {
version: None,
latest: true,
unsafe_mutable: false
}
}
));
}
#[test]
fn frost_checkout_rejects_latest_with_version() {
let error =
Cli::try_parse_from(["sirno", "frost", "checkout", "3", "--latest"]).unwrap_err();
assert_eq!(error.kind(), clap::error::ErrorKind::ArgumentConflict);
}
#[test]
fn frost_checkout_rejects_latest_with_unsafe_mutable() {
let error =
Cli::try_parse_from(["sirno", "frost", "checkout", "--latest", "--unsafe-mutable"])
.unwrap_err();
assert_eq!(error.kind(), clap::error::ErrorKind::ArgumentConflict);
}
#[test]
fn freeze_accepts_entry_id() {
let cli = Cli::parse_from(["sirno", "freeze", "alpha"]);
assert!(matches!(
cli.command,
Command::TopLevelEntry(EntryCommand::Freeze { id, .. }) if id == "alpha"
));
}
#[test]
fn new_accepts_short_metadata_flags() {
let cli = Cli::parse_from([
"sirno",
"new",
"alpha",
"-n",
"Alpha",
"-d",
"Alpha desc.",
"-b",
"Alpha body.",
]);
assert!(matches!(
cli.command,
Command::TopLevelEntry(EntryCommand::New {
id,
name: Some(name),
desc,
body: Some(body),
..
})
if id == "alpha"
&& name == "Alpha"
&& desc == "Alpha desc."
&& body == "Alpha body."
));
}
#[test]
fn new_accepts_structural_targets() {
let cli = Cli::parse_from([
"sirno",
"new",
"alpha",
"-d",
"Alpha desc.",
"--structural",
"topic=concept",
"--structural",
"topic=methodology",
]);
assert!(matches!(
cli.command,
Command::TopLevelEntry(EntryCommand::New { structural, .. })
if structural == vec![
CliStructuralPredicate {
field: "topic".to_owned(),
target: EntryId::new("concept").unwrap(),
},
CliStructuralPredicate {
field: "topic".to_owned(),
target: EntryId::new("methodology").unwrap(),
},
]
));
}
#[test]
fn rename_accepts_entry_ids_and_aliases() {
let top_level = Cli::parse_from(["sirno", "rename", "old-entry", "new-entry"]);
let grouped = Cli::parse_from(["sirno", "entry", "rename", "old-entry", "new-entry"]);
let short = Cli::parse_from(["sirno", "entry", "mv", "old-entry", "new-entry"]);
let mnemonic = Cli::parse_from(["sirno", "entry", "move", "old-entry", "new-entry"]);
assert!(matches!(
top_level.command,
Command::TopLevelEntry(EntryCommand::Rename { old_id, new_id })
if old_id == "old-entry" && new_id == "new-entry"
));
assert!(matches!(
grouped.command,
Command::Entry { command: GroupedEntryCommand::Rename { old_id, new_id } }
if old_id == "old-entry" && new_id == "new-entry"
));
assert!(matches!(
short.command,
Command::Entry { command: GroupedEntryCommand::Rename { old_id, new_id } }
if old_id == "old-entry" && new_id == "new-entry"
));
assert!(matches!(
mnemonic.command,
Command::Entry { command: GroupedEntryCommand::Rename { old_id, new_id } }
if old_id == "old-entry" && new_id == "new-entry"
));
}
#[test]
fn entry_new_creates_entry() {
let temp = tempfile::tempdir().unwrap();
let config_path = temp.path().join(CONFIG_FILE_NAME);
let docs = temp.path().join("docs");
SirnoConfig::new("docs").write_new(&config_path).unwrap();
fs::create_dir(&docs).unwrap();
Cli::parse_from([
"sirno",
"--config",
config_path.to_str().unwrap(),
"entry",
"new",
"alpha",
"--desc",
"Alpha entry.",
])
.run()
.unwrap();
assert!(docs.join("alpha.md").exists());
}
#[test]
fn new_rejects_exact_short_alias() {
let error = Cli::try_parse_from([
"sirno",
"new",
"alpha",
"-d",
"Alpha desc.",
"-x",
"topic=concept",
])
.unwrap_err();
assert_eq!(error.kind(), clap::error::ErrorKind::UnknownArgument);
}
#[test]
fn lake_path_is_global() {
let cli = Cli::parse_from(["sirno", "freeze", "alpha", "--lake-path", "scratch-docs"]);
assert_eq!(cli.lake_path.as_deref(), Some(Path::new("scratch-docs")));
assert!(matches!(
cli.command,
Command::TopLevelEntry(EntryCommand::Freeze { id }) if id == "alpha"
));
}
#[test]
fn lake_path_conflicts_with_frost_path_check() {
let error = Cli::parse_from([
"sirno",
"--lake-path",
"scratch-docs",
"check",
"--frost-path",
"sirno-frost",
])
.run()
.unwrap_err();
assert!(matches!(error, CliError::LakePathWithFrostPath));
}
#[test]
fn check_rejects_old_frost_root_flag() {
let error =
Cli::try_parse_from(["sirno", "check", "--frost-root", "sirno-frost"]).unwrap_err();
assert_eq!(error.kind(), clap::error::ErrorKind::UnknownArgument);
}
#[test]
fn query_accepts_exact_structural_predicate() {
let cli = Cli::parse_from(["sirno", "query", "--exact", "topic=concept"]);
assert!(matches!(
cli.command,
Command::TopLevelEntry(EntryCommand::Query { exact, .. })
if exact == vec![CliStructuralPredicate {
field: "topic".to_owned(),
target: EntryId::new("concept").unwrap(),
}]
));
}
#[test]
fn query_accepts_short_alias_and_options() {
let cli =
Cli::parse_from(["sirno", "q", "-x", "topic=concept", "-f", "id,path", "-o", "human"]);
let Command::TopLevelEntry(EntryCommand::Query {
exact,
fields: Some(fields),
format: Some(format),
..
}) = cli.command
else {
panic!("expected query command with short options");
};
assert_eq!(
exact,
vec![CliStructuralPredicate {
field: "topic".to_owned(),
target: EntryId::new("concept").unwrap(),
}]
);
assert_eq!(fields.fields, vec![CliQueryField::Id, CliQueryField::Path]);
assert!(matches!(format, CliQueryOutputFormat::Human));
}
#[test]
fn entry_query_accepts_short_alias_and_options() {
let cli = Cli::parse_from([
"sirno",
"entry",
"q",
"-x",
"topic=concept",
"-f",
"id,path",
"-o",
"human",
]);
let Command::Entry {
command:
GroupedEntryCommand::Query { exact, fields: Some(fields), format: Some(format), .. },
} = cli.command
else {
panic!("expected grouped query command with short options");
};
assert_eq!(
exact,
vec![CliStructuralPredicate {
field: "topic".to_owned(),
target: EntryId::new("concept").unwrap(),
}]
);
assert_eq!(fields.fields, vec![CliQueryField::Id, CliQueryField::Path]);
assert!(matches!(format, CliQueryOutputFormat::Human));
}
#[test]
fn query_accepts_comma_separated_fields() {
let cli = Cli::parse_from(["sirno", "query", "--fields", "id,name,path,desc"]);
let Command::TopLevelEntry(EntryCommand::Query { fields: Some(fields), .. }) = cli.command
else {
panic!("expected query command with fields");
};
assert_eq!(
fields.fields,
vec![CliQueryField::Id, CliQueryField::Name, CliQueryField::Path, CliQueryField::Desc,]
);
}
#[test]
fn query_accepts_json_format() {
let cli = Cli::parse_from(["sirno", "query", "--format", "json"]);
assert!(matches!(
cli.command,
Command::TopLevelEntry(EntryCommand::Query {
format: Some(CliQueryOutputFormat::Json),
..
})
));
}
#[test]
fn query_accepts_human_format() {
let cli = Cli::parse_from(["sirno", "query", "--format", "human"]);
assert!(matches!(
cli.command,
Command::TopLevelEntry(EntryCommand::Query {
format: Some(CliQueryOutputFormat::Human),
..
})
));
}
#[test]
fn query_rejects_old_human_flag() {
let error = Cli::try_parse_from(["sirno", "query", "--human"]).unwrap_err();
assert_eq!(error.kind(), clap::error::ErrorKind::UnknownArgument);
}
#[test]
fn query_rejects_old_format_field_list() {
let error = Cli::try_parse_from(["sirno", "query", "--format", "id,desc"]).unwrap_err();
assert_eq!(error.kind(), clap::error::ErrorKind::InvalidValue);
}
#[test]
fn query_rejects_unknown_field() {
let error = Cli::try_parse_from(["sirno", "query", "--fields", "id,summary"]).unwrap_err();
assert_eq!(error.kind(), clap::error::ErrorKind::ValueValidation);
}
#[test]
fn query_rejects_empty_field() {
let error = Cli::try_parse_from(["sirno", "query", "--fields", "id,,desc"]).unwrap_err();
assert_eq!(error.kind(), clap::error::ErrorKind::ValueValidation);
}
#[test]
fn query_json_uses_selected_field_names() {
let fields = "id,desc".parse::<CliQueryFields>().unwrap();
let json = format_query_json(&fields, &[vec!["query".to_owned(), "Selection".to_owned()]])
.unwrap();
let parsed = serde_json::from_str::<serde_json::Value>(&json).unwrap();
assert_eq!(
json,
"\
[
{
\"id\": \"query\",
\"desc\": \"Selection\"
}
]"
);
assert_eq!(parsed, serde_json::json!([{ "id": "query", "desc": "Selection" }]));
}
#[test]
fn query_table_uses_selected_field_headers_and_widths() {
let fields = "id,desc".parse::<CliQueryFields>().unwrap();
let table =
format_query_table(&fields, &[vec!["query".to_owned(), "Selection".to_owned()]]);
assert_eq!(
table,
"\
| id | desc |
| ----- | --------- |
| query | Selection |
"
);
}
#[test]
fn query_rejects_old_exact_structural_flags() {
let error =
Cli::try_parse_from(["sirno", "query", "--exact-topic", "concept"]).unwrap_err();
assert_eq!(error.kind(), clap::error::ErrorKind::UnknownArgument);
}
#[test]
fn check_accepts_short_mode() {
let cli = Cli::parse_from(["sirno", "check", "-m", "review"]);
assert!(matches!(
cli.command,
Command::TopLevelLake(LakeCommand::Check { mode: Some(CliCheckMode::Review), .. })
));
}
#[test]
fn rg_accepts_forwarded_arguments() {
let cli = Cli::parse_from(["sirno", "rg", "--json", "metadata"]);
assert!(matches!(
cli.command,
Command::TopLevelEntry(EntryCommand::Rg { with_generated_footer: false, args })
if args == vec![OsString::from("--json"), OsString::from("metadata")]
));
}
#[test]
fn rg_accepts_generated_footer_inclusion_flag() {
let cli = Cli::parse_from(["sirno", "rg", "--with-generated-footer", "metadata"]);
assert!(matches!(
cli.command,
Command::TopLevelEntry(EntryCommand::Rg { with_generated_footer: true, args })
if args == vec![OsString::from("metadata")]
));
}
#[test]
fn rg_detects_forwarded_preprocessor_arguments() {
assert!(rg_args_include_preprocessor(&[OsString::from("--pre"), OsString::from("cat")]));
assert!(rg_args_include_preprocessor(&[OsString::from("--pre=cat")]));
assert!(!rg_args_include_preprocessor(&[
OsString::from("--pre-glob"),
OsString::from("*.md")
]));
}
#[test]
fn rg_requires_forwarded_arguments() {
let error = Cli::try_parse_from(["sirno", "rg"]).unwrap_err();
assert_eq!(error.kind(), clap::error::ErrorKind::MissingRequiredArgument);
}
#[test]
fn exact_query_rejects_unconfigured_structural_field() {
let error = exact_query_from_predicates(
EntryQuery::new(),
vec!["topic=concept".parse::<CliStructuralPredicate>().unwrap()],
&StructuralSettings::default(),
)
.unwrap_err();
assert!(matches!(error, CliError::UnconfiguredStructuralField(field) if field == "topic"));
}
#[test]
fn exact_query_keeps_repeated_field_targets_disjunctive() {
let mut metadata = EntryMetadata::new("Concept", "A named idea.").unwrap();
metadata.push_structural_target("topic", EntryId::new("meta").unwrap());
let entry = Entry::new(EntryId::new("concept").unwrap(), metadata, "");
let settings =
StructuralSettings::from_fields([("topic", StructuralFieldSettings::default())]);
let query = exact_query_from_predicates(
EntryQuery::new(),
vec![
"topic=concept".parse::<CliStructuralPredicate>().unwrap(),
"topic=meta".parse::<CliStructuralPredicate>().unwrap(),
],
&settings,
)
.unwrap();
assert!(query.matches(&entry));
}
#[test]
fn subcommands_reject_entries_flag() {
let error = Cli::try_parse_from(["sirno", "freeze", "alpha", "--entries", "scratch-docs"])
.unwrap_err();
assert_eq!(error.kind(), clap::error::ErrorKind::UnknownArgument);
}
#[test]
fn melt_accepts_entry_id_and_unfreeze_alias() {
let melt = Cli::parse_from(["sirno", "melt", "alpha"]);
let unfreeze = Cli::parse_from(["sirno", "unfreeze", "alpha"]);
assert!(matches!(
melt.command,
Command::TopLevelEntry(EntryCommand::Melt { id, .. }) if id == "alpha"
));
assert!(matches!(
unfreeze.command,
Command::TopLevelEntry(EntryCommand::Melt { id, .. }) if id == "alpha"
));
}
#[test]
fn move_moves_lake_and_rewrites_config() {
let temp = tempfile::tempdir().unwrap();
let config_path = temp.path().join(CONFIG_FILE_NAME);
let old_lake = temp.path().join("docs");
let new_lake = temp.path().join("sirno-docs");
let config = SirnoConfig {
structural: StructuralSettings::from_fields([
("zeta", StructuralFieldSettings::default()),
("area", StructuralFieldSettings::default()),
]),
..SirnoConfig::new("docs")
};
config.write_new(&config_path).unwrap();
fs::create_dir(&old_lake).unwrap();
fs::write(old_lake.join("entry.md"), "entry").unwrap();
Cli::parse_from(["sirno", "--config", config_path.to_str().unwrap(), "move", "sirno-docs"])
.run()
.unwrap();
let config = SirnoConfig::from_file(&config_path).unwrap();
let source = fs::read_to_string(&config_path).unwrap();
assert_eq!(config.lake.path, PathBuf::from("sirno-docs"));
assert_before(&source, "zeta = ", "area = ");
assert!(!old_lake.exists());
assert!(new_lake.join("entry.md").exists());
}
#[test]
fn move_refuses_existing_destination() {
let temp = tempfile::tempdir().unwrap();
let config_path = temp.path().join(CONFIG_FILE_NAME);
let old_lake = temp.path().join("docs");
let new_lake = temp.path().join("sirno-docs");
SirnoConfig::new("docs").write_new(&config_path).unwrap();
fs::create_dir(&old_lake).unwrap();
fs::create_dir(&new_lake).unwrap();
let error = Cli::parse_from([
"sirno",
"--config",
config_path.to_str().unwrap(),
"move",
"sirno-docs",
])
.run()
.unwrap_err();
assert!(matches!(error, CliError::MoveDestinationExists(_)));
let config = SirnoConfig::from_file(&config_path).unwrap();
assert_eq!(config.lake.path, PathBuf::from("docs"));
assert!(old_lake.exists());
}
#[test]
fn frost_move_moves_frost_and_rewrites_config() {
let temp = tempfile::tempdir().unwrap();
let config_path = temp.path().join(CONFIG_FILE_NAME);
let old_frost = temp.path().join("sirno-frost");
let new_frost = temp.path().join("frost");
let config = SirnoConfig {
structural: StructuralSettings::from_fields([
("zeta", StructuralFieldSettings::default()),
("area", StructuralFieldSettings::default()),
]),
..SirnoConfig::new("docs").with_frost("sirno-frost")
};
config.write_new(&config_path).unwrap();
fs::create_dir(&old_frost).unwrap();
fs::write(old_frost.join("row"), "frost").unwrap();
Cli::parse_from([
"sirno",
"--config",
config_path.to_str().unwrap(),
"frost",
"move",
"frost",
])
.run()
.unwrap();
let config = SirnoConfig::from_file(&config_path).unwrap();
let source = fs::read_to_string(&config_path).unwrap();
assert_eq!(config.frost, Some(FrostSettings { path: PathBuf::from("frost") }));
assert_before(&source, "zeta = ", "area = ");
assert!(!old_frost.exists());
assert!(new_frost.join("row").exists());
}
#[test]
fn freeze_and_melt_commands_toggle_marker_and_permissions() {
let temp = tempfile::tempdir().unwrap();
let config_path = temp.path().join(CONFIG_FILE_NAME);
let docs = temp.path().join("docs");
SirnoConfig::new("docs").write_new(&config_path).unwrap();
fs::create_dir(&docs).unwrap();
fs::write(
docs.join("alpha.md"),
"\
---
name: Alpha
desc: Alpha entry.
---
Body.
",
)
.unwrap();
Cli::parse_from(["sirno", "--config", config_path.to_str().unwrap(), "freeze", "alpha"])
.run()
.unwrap();
let source = fs::read_to_string(docs.join("alpha.md")).unwrap();
assert!(source.contains("frozen:\n"));
assert!(fs::metadata(docs.join("alpha.md")).unwrap().permissions().readonly());
Cli::parse_from(["sirno", "--config", config_path.to_str().unwrap(), "melt", "alpha"])
.run()
.unwrap();
let source = fs::read_to_string(docs.join("alpha.md")).unwrap();
assert!(!source.contains("frozen:\n"));
assert!(!fs::metadata(docs.join("alpha.md")).unwrap().permissions().readonly());
}
#[test]
fn rename_command_updates_lake_and_witness_references() {
let temp = tempfile::tempdir().unwrap();
let config_path = temp.path().join(CONFIG_FILE_NAME);
let docs = temp.path().join("docs");
let src = temp.path().join("src");
SirnoConfig {
repo: Some(RepoSettings { members: vec![RepoMember::new("src").unwrap()] }),
structural: StructuralSettings::from_fields([(
"area",
StructuralFieldSettings::default(),
)]),
..SirnoConfig::new("docs")
}
.write_new(&config_path)
.unwrap();
fs::create_dir(&docs).unwrap();
fs::create_dir(&src).unwrap();
fs::write(
docs.join("old-entry.md"),
"\
---
name: Old
desc: Old entry.
---
Body.
",
)
.unwrap();
fs::write(
docs.join("reader.md"),
"\
---
name: Reader
desc: Reader entry.
area:
- old-entry
---
Body.
",
)
.unwrap();
let witness_source = format!(
"\
// sirno{}old-entry:begin
fn sample() {{}}
// sirno{}old-entry:end
",
":witness:", ":witness:"
);
fs::write(src.join("lib.rs"), witness_source).unwrap();
Cli::parse_from([
"sirno",
"--config",
config_path.to_str().unwrap(),
"entry",
"rename",
"old-entry",
"new-entry",
])
.run()
.unwrap();
let reader_source = fs::read_to_string(docs.join("reader.md")).unwrap();
let witness_source = fs::read_to_string(src.join("lib.rs")).unwrap();
assert!(!docs.join("old-entry.md").exists());
assert!(docs.join("new-entry.md").exists());
assert!(reader_source.contains("area:\n - new-entry\n"));
assert!(witness_source.contains("sirno:witness:new-entry:begin"));
assert!(witness_source.contains("sirno:witness:new-entry:end"));
}
#[test]
fn lake_path_override_targets_public_lake_commands() {
let temp = tempfile::tempdir().unwrap();
let config_path = temp.path().join(CONFIG_FILE_NAME);
let configured_docs = temp.path().join("docs");
let override_docs = temp.path().join("scratch-docs");
SirnoConfig::new("docs").write_new(&config_path).unwrap();
fs::create_dir(&configured_docs).unwrap();
fs::create_dir(&override_docs).unwrap();
let entry = "\
---
name: Alpha
desc: Alpha entry.
---
Body.
";
fs::write(configured_docs.join("alpha.md"), entry).unwrap();
fs::write(override_docs.join("alpha.md"), entry).unwrap();
Cli::parse_from([
"sirno",
"--config",
config_path.to_str().unwrap(),
"freeze",
"alpha",
"--lake-path",
override_docs.to_str().unwrap(),
])
.run()
.unwrap();
assert!(!fs::read_to_string(configured_docs.join("alpha.md")).unwrap().contains("frozen:"));
assert!(fs::read_to_string(override_docs.join("alpha.md")).unwrap().contains("frozen:"));
}
#[test]
fn new_rejects_witness_flag() {
let error = Cli::try_parse_from(["sirno", "new", "alpha", "--desc", "Alpha.", "--witness"])
.unwrap_err();
assert_eq!(error.kind(), clap::error::ErrorKind::UnknownArgument);
}
#[test]
fn new_rejects_old_description_flag() {
let error =
Cli::try_parse_from(["sirno", "new", "alpha", "--description", "Alpha."]).unwrap_err();
assert_eq!(error.kind(), clap::error::ErrorKind::UnknownArgument);
}
#[test]
fn witness_accepts_entry_id() {
let cli = Cli::parse_from(["sirno", "witness", "witness"]);
assert!(matches!(
cli.command,
Command::TopLevelEntry(EntryCommand::Witness { id, full: false }) if id == "witness"
));
}
#[test]
fn status_accepts_short_alias() {
let cli = Cli::parse_from(["sirno", "st"]);
assert!(matches!(cli.command, Command::TopLevelLake(LakeCommand::Status)));
}
#[test]
fn witness_accepts_short_aliases() {
let short = Cli::parse_from(["sirno", "w", "alpha"]);
let mnemonic = Cli::parse_from(["sirno", "wit", "beta"]);
assert!(matches!(
short.command,
Command::TopLevelEntry(EntryCommand::Witness { id, full: false }) if id == "alpha"
));
assert!(matches!(
mnemonic.command,
Command::TopLevelEntry(EntryCommand::Witness { id, full: false }) if id == "beta"
));
}
#[test]
fn lake_subcommand_accepts_status_alias() {
let status = Cli::parse_from(["sirno", "lake", "st"]);
assert!(matches!(status.command, Command::Lake { command: LakeCommand::Status }));
}
#[test]
fn lake_subcommand_rejects_entry_aliases() {
let error = Cli::try_parse_from(["sirno", "lake", "q"]).unwrap_err();
assert_eq!(error.kind(), clap::error::ErrorKind::InvalidSubcommand);
}
#[test]
fn entry_subcommand_accepts_common_aliases() {
let short_query = Cli::parse_from(["sirno", "entry", "q", "alpha"]);
let short_witness = Cli::parse_from(["sirno", "entry", "w", "alpha"]);
let mnemonic_witness = Cli::parse_from(["sirno", "entry", "wit", "beta"]);
assert!(matches!(
short_query.command,
Command::Entry { command: GroupedEntryCommand::Query { terms, .. } }
if terms == vec!["alpha"]
));
assert!(matches!(
short_witness.command,
Command::Entry { command: GroupedEntryCommand::Witness { id, full: false } }
if id == "alpha"
));
assert!(matches!(
mnemonic_witness.command,
Command::Entry { command: GroupedEntryCommand::Witness { id, full: false } }
if id == "beta"
));
}
#[test]
fn witness_accepts_full_flag() {
let cli = Cli::parse_from(["sirno", "witness", "witness", "--full"]);
assert!(matches!(
cli.command,
Command::TopLevelEntry(EntryCommand::Witness { id, full: true }) if id == "witness"
));
}
#[test]
fn witness_accepts_short_full_flag() {
let cli = Cli::parse_from(["sirno", "witness", "witness", "-f"]);
assert!(matches!(
cli.command,
Command::TopLevelEntry(EntryCommand::Witness { id, full: true }) if id == "witness"
));
}
#[test]
fn witness_rejects_missing_entry_before_repo_scan() {
let temp = tempfile::tempdir().unwrap();
let config_path = temp.path().join(CONFIG_FILE_NAME);
fs::create_dir(temp.path().join("docs")).unwrap();
SirnoConfig {
repo: Some(RepoSettings { members: vec![RepoMember::new("missing-src").unwrap()] }),
..SirnoConfig::new("docs")
}
.write_new(&config_path)
.unwrap();
let error = Cli::parse_from([
"sirno",
"--config",
config_path.to_str().unwrap(),
"witness",
"missing-entry",
])
.run()
.unwrap_err();
assert!(
matches!(error, CliError::MissingWitnessEntry(id) if id.as_str() == "missing-entry")
);
}
#[test]
fn format_witness_record_prints_range_and_preserves_body() {
let record = WitnessRecord {
entry: EntryId::new("entry").unwrap(),
path: PathBuf::from("src/lib.rs"),
region: witness_span(10, 5, 14, 25),
opening: witness_span(10, 5, 10, 33),
closing: witness_span(14, 5, 14, 25),
marker: " // sample:start entry".to_owned(),
body: concat!(
" // sample:start entry\n",
" fn main() {}\n",
" // sample:end"
)
.to_owned(),
};
assert_eq!(
format_witness_record(&record, false),
"src/lib.rs:10:5-33 :: 14:5-25\t // sample:start entry\n"
);
assert_eq!(
format_witness_record(&record, true),
concat!(
"src/lib.rs:10:5-33 :: 14:5-25\n",
"\n",
" // sample:start entry\n",
" fn main() {}\n",
" // sample:end\n",
"\n",
)
);
}
#[test]
fn format_witness_records_adds_full_region_spacing() {
let first = WitnessRecord {
entry: EntryId::new("entry").unwrap(),
path: PathBuf::from("src/lib.rs"),
region: witness_span(10, 5, 14, 25),
opening: witness_span(10, 5, 10, 33),
closing: witness_span(14, 5, 14, 25),
marker: " // sample:start entry".to_owned(),
body: concat!(
" // sample:start entry\n",
" fn main() {}\n",
" // sample:end"
)
.to_owned(),
};
let mut second = first.clone();
second.region = witness_span(20, 5, 24, 25);
second.opening = witness_span(20, 5, 20, 33);
second.closing = witness_span(24, 5, 24, 25);
assert!(format_witness_records(&[first, second], true).contains(concat!(
" // sample:end\n",
"\n",
"---\n",
"\n",
"src/lib.rs:20:5-33 :: 24:5-25\n",
)));
}
fn witness_span(
start_line: usize, start_column: usize, end_line: usize, end_column: usize,
) -> WitnessSpan {
WitnessSpan { start_line, start_column, end_line, end_column }
}
#[test]
fn gen_link_rejects_no_check_flag() {
let error = Cli::try_parse_from(["sirno", "gen-link", "--no-check"]).unwrap_err();
assert!(error.to_string().contains("unexpected argument"));
}
#[test]
fn gen_link_accepts_dry_flag() {
let cli = Cli::parse_from(["sirno", "gen-link", "--dry"]);
assert!(matches!(
cli.command,
Command::TopLevelLake(LakeCommand::GenLink { dry: true, command: None, .. })
));
}
#[test]
fn gen_link_accepts_dry_run_aliases() {
let short = Cli::parse_from(["sirno", "gen-link", "-n"]);
let long = Cli::parse_from(["sirno", "gen-link", "--dry-run"]);
assert!(matches!(
short.command,
Command::TopLevelLake(LakeCommand::GenLink { dry: true, command: None, .. })
));
assert!(matches!(
long.command,
Command::TopLevelLake(LakeCommand::GenLink { dry: true, command: None, .. })
));
}
#[test]
fn format_gen_link_report_lists_changed_paths() {
let report = format_gen_link_report(
Path::new("sirno-docs"),
31,
&[PathBuf::from("sirno-docs/concept.md"), PathBuf::from("sirno-docs/entry.md")],
);
assert_eq!(
report,
"Changes in sirno-docs:\n- sirno-docs/concept.md\n- sirno-docs/entry.md\nTotal changes: 2/31"
);
}
#[test]
fn format_gen_link_report_summarizes_no_changes() {
let report = format_gen_link_report(Path::new("sirno-docs"), 31, &[]);
assert_eq!(report, "No changes in sirno-docs");
}
}