mod config;
mod entry;
mod freeze;
mod skills;
mod tide;
mod tui;
use std::collections::{BTreeMap, BTreeSet};
use std::ffi::OsString;
use std::io::{self, BufRead, Write};
use std::path::{Path, PathBuf};
use std::process::ExitCode;
use std::str::FromStr;
use clap::{ArgGroup, Args, CommandFactory, Parser, Subcommand, ValueEnum};
use clap_complete::{Shell, generate};
use serde::Deserialize;
use thiserror::Error;
use crate::surface::SurfaceContext;
use crate::surface::context::{default_config_path, default_frost_path, default_lake_path};
use crate::surface::dto::{
ArtifactAddRequest, ArtifactRemoveRequest, ArtifactRenameRequest, EntryNewRequest,
EntryPathsRequest, FrostCheckoutRequest, LakeInitRequest, LocalProtectionResult, PathRecord,
PathSelection, QueryColumnSelection, QueryColumns, QueryOutputFormat, QueryRequest, QueryRun,
RgRequest, SkillWrapperResult, StructuralFilter, StructuralStateFilter, StructuralTarget,
TideOutputFormat, TideResolveRequest, TideSelectionRequest, TideStatusMode, UpstreamAddRequest,
UpstreamCrystallizeRequest,
};
use crate::surface::error::CommandError;
use crate::surface::output::{
OutputStyle, format_human_table_semantic_with_width, format_muted_text, format_path_table,
format_skill_wrapper_table_for_terminal, format_success_text, format_warning_text,
print_check_diagnostic, print_check_summary, print_cli_error, print_config_comment_result,
print_entry_directory_report, print_json, print_lake_check_result, print_ok_path,
print_query_column_options, print_query_results, print_render_result, print_status_result,
print_upstream_crystallize_report, print_upstream_status_report, print_witness_records,
};
use crate::surface::rg::{
is_rg_preprocessor_invocation, rg_args_to_strings, run_rg_preprocessor_from_env,
};
use crate::{
CheckMode, EntryAddress, EntryAddressError, EntryAtom, SirnoConfig, SirnoFrost, TideSource,
TideStatus, TideWorkitem, TideWorkitemParseError, UpstreamSettings,
};
#[cfg(test)]
use crate::surface::context::entry_query_from_filters;
#[cfg(test)]
use crate::surface::dto::{QueryColumn, QueryValue, StructuralFieldState};
#[cfg(test)]
use crate::surface::error::OpenTideTutorial;
#[cfg(test)]
use crate::surface::output::{
format_config_comment_result, format_gen_link_report, format_human_table_with_width,
format_json, format_lake_check_result, format_query_json, format_query_table,
format_render_result, format_skill_wrapper_table, format_status_result, format_witness_record,
format_witness_records,
};
#[cfg(test)]
use crate::surface::rg::rg_args_include_preprocessor;
#[derive(Debug, Parser)]
#[command(name = "sirno")]
#[command(about = "Manage Sirno design entries")]
#[command(version)]
pub struct Cli {
#[arg(short = 'C', long, global = true)]
config: Option<PathBuf>,
#[arg(short = 'L', long = "lake-path", global = true)]
lake_path: Option<PathBuf>,
#[arg(short = 'F', long = "frost-path", global = true)]
frost_path: Option<PathBuf>,
#[command(subcommand)]
command: Command,
}
#[derive(Debug, Subcommand)]
enum Command {
Init {
#[arg(long)]
all: bool,
#[arg(long)]
lake: Option<PathBuf>,
#[arg(long)]
frost: Option<PathBuf>,
#[arg(long = "no-lake", conflicts_with = "lake")]
no_lake: bool,
#[arg(long = "no-frost", conflicts_with = "frost")]
no_frost: bool,
#[arg(long = "no-skills")]
no_skills: bool,
#[arg(long = "claude-skills", conflicts_with = "no_skills")]
claude_skills: bool,
},
#[command(visible_alias = "mv")]
Move {
#[command(subcommand)]
command: MoveCommand,
},
Entry {
#[command(subcommand)]
command: EntryCommand,
},
Lake {
#[command(subcommand)]
command: LakeCommand,
},
Frost {
#[command(subcommand)]
command: FrostCommand,
},
Upstream {
#[command(subcommand)]
command: UpstreamCommand,
},
Tide {
#[command(subcommand)]
command: Option<TideCommand>,
},
#[command(visible_alias = "st")]
Status,
#[command(flatten)]
TopLevelEntry(TopLevelEntryCommand),
#[command(flatten)]
TopLevelLake(TopLevelLakeCommand),
#[command(flatten)]
TopLevelFrost(TopLevelFrostCommand),
#[command(flatten)]
TopLevelTide(TideReviewCommand),
Util {
#[command(subcommand)]
command: UtilCommand,
},
}
#[derive(Debug, Subcommand)]
enum EntryCommand {
#[command(flatten)]
TopLevel(TopLevelEntryCommand),
#[command(visible_aliases = ["mv", "move"])]
Rename(EntryRenameArgs),
Path(EntryPathsArgs),
}
#[derive(Debug, Subcommand)]
enum TopLevelEntryCommand {
New {
#[arg(value_name = "ENTRY_ADDRESS")]
id: String,
#[arg(short = 'n', long)]
name: Option<String>,
#[arg(short = 'd', long)]
desc: String,
#[arg(long = "structural", value_name = "FIELD=ENTRY_ADDRESS")]
structural: Vec<StructuralPredicate>,
#[arg(short = 'b', long)]
body: Option<String>,
},
Freeze {
#[arg(value_name = "ENTRY_ADDRESS", conflicts_with = "fix_all")]
id: Option<String>,
#[arg(long = "fix-all", conflicts_with = "id")]
fix_all: bool,
#[arg(long = "dry-run", requires = "fix_all")]
dry_run: bool,
},
#[command(visible_alias = "unfreeze")]
Melt {
#[arg(value_name = "ENTRY_ADDRESS", conflicts_with = "unsafe_all")]
id: Option<String>,
#[arg(long = "unsafe-all", conflicts_with = "id")]
unsafe_all: bool,
#[arg(long = "dry-run", requires = "unsafe_all")]
dry_run: bool,
},
#[command(visible_alias = "q")]
Query {
terms: Vec<String>,
#[arg(long = "exact-term")]
exact_terms: Vec<String>,
#[arg(long = "has", value_name = "FIELD=ENTRY_ADDRESS[,ENTRY_ADDRESS]")]
has: Vec<StructuralFilter>,
#[arg(long = "is", value_name = "FIELD=STATE")]
is: Vec<StructuralStateFilter>,
#[arg(long = "columns", alias = "column", value_name = "COLUMNS", num_args = 0..=1)]
columns: Option<Option<QueryColumns>>,
#[arg(short = 'o', long, value_enum)]
format: Option<QueryOutputFormat>,
},
Rg {
#[arg(long = "with-generated-footer")]
with_generated_footer: bool,
#[arg(required = true, trailing_var_arg = true, allow_hyphen_values = true)]
args: Vec<OsString>,
},
Artifact {
#[command(subcommand)]
command: ArtifactCommand,
},
#[command(visible_aliases = ["w", "wit"])]
Witness {
#[arg(value_name = "ENTRY_ADDRESS")]
id: String,
#[arg(short = 'f', long)]
full: bool,
},
}
#[derive(Debug, Subcommand)]
enum LakeCommand {
Init {
lake: Option<PathBuf>,
},
#[command(visible_alias = "mv")]
Move(LakeMoveArgs),
#[command(flatten)]
TopLevel(TopLevelLakeCommand),
}
#[derive(Debug, Subcommand)]
enum TopLevelLakeCommand {
Check {
#[arg(short = 'm', long, value_enum)]
mode: Option<CheckModeArg>,
},
Render {
#[arg(short = 'n', long, visible_alias = "dry-run")]
dry: bool,
#[arg(long = "override-json", value_name = "JSON")]
override_json: Option<String>,
#[command(subcommand)]
command: Option<RenderCommand>,
},
}
#[derive(Debug, Subcommand)]
enum MoveCommand {
Entry(EntryRenameArgs),
Lake(LakeMoveArgs),
Frost(FrostMoveArgs),
}
#[derive(Debug, Args)]
struct EntryRenameArgs {
#[arg(value_name = "OLD_ENTRY_ADDRESS")]
old_id: String,
#[arg(value_name = "NEW_ENTRY_ADDRESS")]
new_id: String,
}
#[derive(Debug, Args)]
struct LakeMoveArgs {
lake: PathBuf,
}
#[derive(Debug, Args)]
struct FrostMoveArgs {
frost: PathBuf,
}
#[derive(Clone, Debug, Args)]
struct EntryPathsArgs {
#[arg(value_name = "ENTRY_ADDRESS")]
id: String,
#[arg(long = "entry")]
show_entry: bool,
#[arg(long = "artifact")]
show_artifact: bool,
#[arg(long = "frost")]
show_frost: bool,
#[arg(long)]
absolute: bool,
#[arg(short = 'o', long, value_enum)]
format: Option<PathOutputFormat>,
}
#[derive(Clone, Copy, Debug, Default, ValueEnum)]
enum PathOutputFormat {
Json,
#[default]
Human,
Paths,
}
#[derive(Clone, Debug, PartialEq, Eq)]
struct StructuralPredicate {
field: String,
target: EntryAddress,
}
impl FromStr for StructuralPredicate {
type Err = StructuralPredicateParseError;
fn from_str(raw: &str) -> Result<Self, Self::Err> {
let Some((field, target)) = raw.split_once('=') else {
return Err(StructuralPredicateParseError::MissingEquals);
};
if field.is_empty() {
return Err(StructuralPredicateParseError::EmptyField);
}
let target = EntryAddress::new(target)?;
Ok(Self { field: field.to_owned(), target })
}
}
#[derive(Debug, Error)]
enum StructuralPredicateParseError {
#[error("expected FIELD=ENTRY_ADDRESS")]
MissingEquals,
#[error("structural field name must not be empty")]
EmptyField,
#[error(transparent)]
EntryAddress(#[from] EntryAddressError),
}
#[derive(Debug, Subcommand)]
enum ArtifactCommand {
List {
#[arg(value_name = "ENTRY_ADDRESS")]
id: String,
},
Add {
#[arg(value_name = "ENTRY_ADDRESS")]
id: String,
source: PathBuf,
artifact_path: Option<PathBuf>,
},
#[command(visible_aliases = ["mv", "move"])]
Rename {
#[arg(value_name = "ENTRY_ADDRESS")]
id: String,
old_path: PathBuf,
new_path: PathBuf,
},
#[command(visible_aliases = ["rm", "delete"])]
Remove {
#[arg(value_name = "ENTRY_ADDRESS")]
id: String,
artifact_path: PathBuf,
},
}
#[derive(Clone, Copy, Debug, ValueEnum)]
enum CheckModeArg {
Edit,
Review,
}
impl From<CheckModeArg> for CheckMode {
fn from(value: CheckModeArg) -> Self {
match value {
| CheckModeArg::Edit => CheckMode::Edit,
| CheckModeArg::Review => CheckMode::Review,
}
}
}
#[derive(Debug, Subcommand)]
enum FrostCommand {
Init {
frost: Option<PathBuf>,
},
#[command(visible_alias = "mv")]
Move(FrostMoveArgs),
#[command(flatten)]
Snapshot(TopLevelFrostCommand),
}
#[derive(Debug, Subcommand)]
enum UpstreamCommand {
Add(UpstreamAddArgs),
Remove {
#[arg(value_name = "DOMAIN")]
domain: String,
},
Crystallize {
#[arg(value_name = "DOMAIN")]
domain: Option<String>,
#[arg(long)]
locked: bool,
},
Update {
#[arg(value_name = "DOMAIN")]
domain: Option<String>,
},
Status {
#[arg(short = 'o', long, value_enum)]
format: Option<UpstreamOutputFormat>,
},
}
#[derive(Debug, Args)]
#[command(group(
ArgGroup::new("selector")
.required(true)
.multiple(false)
.args(["branch", "tag", "rev"])
))]
struct UpstreamAddArgs {
#[arg(value_name = "DOMAIN")]
domain: String,
#[arg(long = "git", value_name = "SOURCE")]
git: String,
#[arg(long)]
branch: Option<String>,
#[arg(long)]
tag: Option<String>,
#[arg(long)]
rev: Option<String>,
#[arg(long)]
project: Option<PathBuf>,
}
#[derive(Clone, Copy, Debug, Default, ValueEnum)]
enum UpstreamOutputFormat {
Json,
#[default]
Human,
}
#[derive(Debug, Subcommand)]
enum TopLevelFrostCommand {
Commit {
#[arg(long = "unsafe-resolve-all")]
unsafe_resolve_all: bool,
},
Gc,
Defrost,
Checkout(CheckoutArgs),
}
#[derive(Debug, Args)]
struct CheckoutArgs {
#[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(Clone, Debug, PartialEq, Eq)]
enum TideItemSelector {
Neighbor(EntryAddress),
Workitem(TideWorkitem),
}
impl FromStr for TideItemSelector {
type Err = TideItemSelectorParseError;
fn from_str(raw: &str) -> Result<Self, Self::Err> {
if raw.contains(',') {
return Ok(Self::Workitem(raw.parse()?));
}
Ok(Self::Neighbor(EntryAddress::new(raw)?))
}
}
#[derive(Debug, Error)]
enum TideItemSelectorParseError {
#[error(transparent)]
EntryAddress(#[from] EntryAddressError),
#[error(transparent)]
Workitem(#[from] TideWorkitemParseError),
}
#[derive(Debug, Subcommand)]
enum TideCommand {
Status {
#[arg(long, value_enum, default_value_t = TideStatusMode::Review)]
show: TideStatusMode,
#[arg(long, value_enum, default_value_t = TideStatusGrouping::Entry)]
by: TideStatusGrouping,
#[arg(short = 'o', long, value_enum)]
format: Option<TideOutputFormat>,
},
Tui,
#[command(flatten)]
Review(TideReviewCommand),
Reset,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, ValueEnum)]
enum TideStatusGrouping {
Wave,
#[default]
Entry,
}
#[derive(Debug, Subcommand)]
enum TideReviewCommand {
Resolve(ResolveArgs),
#[command(visible_alias = "reopen")]
Unresolve(UnresolveArgs),
}
#[derive(Debug, Args)]
struct ResolveArgs {
#[arg(long, conflicts_with_all = ["items", "json"])]
infer: bool,
#[arg(long, conflicts_with_all = ["infer", "items"])]
json: Option<String>,
#[arg(required_unless_present_any = ["infer", "json"])]
items: Vec<TideItemSelector>,
}
#[derive(Debug, Args)]
struct UnresolveArgs {
#[arg(required = true)]
items: Vec<TideItemSelector>,
}
#[derive(Debug, Subcommand)]
enum RenderCommand {
Delete,
}
#[derive(Clone, Copy, Debug, ValueEnum)]
enum CompletionShell {
Bash,
Elvish,
Fish,
#[value(name = "powershell", alias = "power-shell")]
PowerShell,
Zsh,
}
impl From<CompletionShell> for Shell {
fn from(value: CompletionShell) -> Self {
match value {
| CompletionShell::Bash => Shell::Bash,
| CompletionShell::Elvish => Shell::Elvish,
| CompletionShell::Fish => Shell::Fish,
| CompletionShell::PowerShell => Shell::PowerShell,
| CompletionShell::Zsh => Shell::Zsh,
}
}
}
#[derive(Debug, Subcommand)]
enum UtilCommand {
Config {
#[command(subcommand)]
command: Option<ConfigUtilityCommand>,
},
Entry {
#[command(subcommand)]
command: Option<EntryUtilityCommand>,
},
Completion {
#[arg(value_enum)]
shell: CompletionShell,
},
Skills {
#[command(subcommand)]
command: Option<SkillCommand>,
},
Mcp,
}
#[derive(Debug, Subcommand)]
enum ConfigUtilityCommand {
Tui,
Check,
Fix,
}
#[derive(Debug, Subcommand)]
enum EntryUtilityCommand {
Tui,
}
#[derive(Debug, Subcommand)]
enum SkillCommand {
Tui(SkillCommandArgs),
Init(SkillCommandArgs),
Check(SkillCommandArgs),
List(SkillCommandArgs),
}
impl Default for SkillCommand {
fn default() -> Self {
Self::Tui(SkillCommandArgs { claude_skills: false })
}
}
#[derive(Debug, Args)]
struct SkillCommandArgs {
#[arg(long = "claude-skills")]
claude_skills: bool,
}
pub fn run_cli_from_env() -> ExitCode {
if is_rg_preprocessor_invocation() {
return match run_rg_preprocessor_from_env() {
| Ok(code) => code,
| Err(error) => {
print_cli_error(&error);
ExitCode::FAILURE
}
};
}
match Cli::parse().run() {
| Ok(code) => code,
| Err(error) => {
print_cli_error(&error);
ExitCode::FAILURE
}
}
}
impl Cli {
pub fn run(self) -> Result<ExitCode, CommandError> {
let config_path = self.config.unwrap_or_else(default_config_path);
let lake_path = self.lake_path;
let frost_path = self.frost_path;
match self.command {
| Command::Init { all, lake, frost, no_lake, no_frost, no_skills, claude_skills } => {
if frost_path.is_some() {
return Err(CommandError::FrostPathRequiresCheck);
}
let request = TopLevelInitRequest {
lake,
frost,
init_lake: !no_lake,
init_frost: !no_frost,
init_skills: !no_skills,
init_claude_skills: claude_skills,
};
if all {
run_top_level_init(request, &config_path, lake_path.as_deref())
} else {
run_interactive_top_level_init(request, &config_path, lake_path.as_deref())
}
}
| Command::Move { command } => {
command.run(&config_path, lake_path.as_deref(), frost_path.as_deref())
}
| Command::Entry { command } => {
if frost_path.is_some() {
return Err(CommandError::FrostPathRequiresCheck);
}
command.run(&config_path, lake_path.as_deref())
}
| Command::Lake { command } => {
command.run(&config_path, lake_path.as_deref(), frost_path.as_deref())
}
| Command::Frost { command } => {
if frost_path.is_some() {
return Err(CommandError::FrostPathRequiresCheck);
}
command.run(&config_path, lake_path.as_deref())
}
| Command::Upstream { command } => {
if frost_path.is_some() {
return Err(CommandError::FrostPathRequiresCheck);
}
command.run(&config_path, lake_path.as_deref())
}
| Command::Tide { command } => {
if frost_path.is_some() {
return Err(CommandError::FrostPathRequiresCheck);
}
command.unwrap_or(TideCommand::Tui).run(&config_path, lake_path.as_deref())
}
| Command::Status => {
if frost_path.is_some() {
return Err(CommandError::FrostPathRequiresCheck);
}
run_status_command(&config_path, lake_path.as_deref())
}
| Command::TopLevelEntry(command) => {
if frost_path.is_some() {
return Err(CommandError::FrostPathRequiresCheck);
}
command.run(&config_path, lake_path.as_deref())
}
| Command::TopLevelLake(command) => {
command.run(&config_path, lake_path.as_deref(), frost_path.as_deref())
}
| Command::TopLevelFrost(command) => {
if frost_path.is_some() {
return Err(CommandError::FrostPathRequiresCheck);
}
command.run(&config_path, lake_path.as_deref())
}
| Command::TopLevelTide(command) => {
if frost_path.is_some() {
return Err(CommandError::FrostPathRequiresCheck);
}
command.run(&config_path, lake_path.as_deref())
}
| Command::Util { command } => {
command.run(&config_path, lake_path.as_deref(), frost_path.as_deref())
}
}
}
}
impl MoveCommand {
fn run(
self, config_path: &Path, lake_path: Option<&Path>, frost_path: Option<&Path>,
) -> Result<ExitCode, CommandError> {
match self {
| Self::Entry(args) => {
if frost_path.is_some() {
return Err(CommandError::FrostPathRequiresCheck);
}
args.run(config_path, lake_path)
}
| Self::Lake(args) => {
if frost_path.is_some() {
return Err(CommandError::FrostPathRequiresCheck);
}
args.run(config_path)
}
| Self::Frost(args) => {
if frost_path.is_some() {
return Err(CommandError::FrostPathRequiresCheck);
}
args.run(config_path)
}
}
}
}
#[derive(Debug)]
struct TopLevelInitRequest {
lake: Option<PathBuf>,
frost: Option<PathBuf>,
init_lake: bool,
init_frost: bool,
init_skills: bool,
init_claude_skills: bool,
}
#[derive(Clone, Copy, Debug)]
enum PromptDefault {
Yes,
No,
}
impl PromptDefault {
fn answer(self) -> bool {
match self {
| Self::Yes => true,
| Self::No => false,
}
}
fn suffix(self, style: OutputStyle) -> String {
match self {
| Self::Yes => {
format!("[{}/{}]", format_success_text("Y", style), format_muted_text("n", style))
}
| Self::No => {
format!("[{}/{}]", format_success_text("y", style), format_muted_text("N", style))
}
}
}
}
fn run_interactive_top_level_init(
request: TopLevelInitRequest, config_path: &Path, lake_path: Option<&Path>,
) -> Result<ExitCode, CommandError> {
let stdin = io::stdin();
let mut input = stdin.lock();
let mut output = anstream::stdout();
run_prompted_top_level_init_with_style(
request,
config_path,
lake_path,
&mut input,
&mut output,
OutputStyle::Styled,
)
}
#[cfg(test)]
fn run_prompted_top_level_init<R: BufRead, W: Write>(
request: TopLevelInitRequest, config_path: &Path, lake_path: Option<&Path>, input: &mut R,
output: &mut W,
) -> Result<ExitCode, CommandError> {
run_prompted_top_level_init_with_style(
request,
config_path,
lake_path,
input,
output,
OutputStyle::Plain,
)
}
fn run_prompted_top_level_init_with_style<R: BufRead, W: Write>(
mut request: TopLevelInitRequest, config_path: &Path, lake_path: Option<&Path>, input: &mut R,
output: &mut W, style: OutputStyle,
) -> Result<ExitCode, CommandError> {
writeln!(output, "Interactive Sirno init").map_err(CommandError::InteractiveInit)?;
if request.init_lake {
request.init_lake =
prompt_yes_no(input, output, "Initialize the lake?", PromptDefault::Yes, style)?;
if request.init_lake && request.lake.is_none() && lake_path.is_none() {
request.lake = prompt_default_path(
input,
output,
"Use default lake path",
"lake path",
default_lake_path(config_path),
style,
)?;
}
}
if request.init_frost {
request.init_frost =
prompt_yes_no(input, output, "Initialize frost?", PromptDefault::Yes, style)?;
if request.init_frost && request.frost.is_none() {
let (prompt, path) = configured_or_default_frost_path(config_path);
request.frost = prompt_default_path(input, output, prompt, "frost path", path, style)?;
}
}
if request.init_skills {
request.init_skills = prompt_yes_no(
input,
output,
"Install packaged skill wrappers?",
PromptDefault::Yes,
style,
)?;
if request.init_skills && !request.init_claude_skills {
request.init_claude_skills = prompt_yes_no(
input,
output,
"Link wrappers into Claude skills?",
PromptDefault::No,
style,
)?;
}
} else {
request.init_claude_skills = false;
}
print_init_plan(output, &request, config_path, lake_path, style)?;
let confirmed =
prompt_yes_no(input, output, "Apply this init plan?", PromptDefault::No, style)?;
if !confirmed {
writeln!(output, "{}", format_warning_text("init cancelled", style))
.map_err(CommandError::InteractiveInit)?;
return Ok(ExitCode::SUCCESS);
}
run_top_level_init(request, config_path, lake_path)
}
fn prompt_yes_no<R: BufRead, W: Write>(
input: &mut R, output: &mut W, question: &str, default: PromptDefault, style: OutputStyle,
) -> Result<bool, CommandError> {
loop {
write!(output, "{question} {} ", default.suffix(style))
.map_err(CommandError::InteractiveInit)?;
output.flush().map_err(CommandError::InteractiveInit)?;
let answer = read_prompt_line(input)?;
match answer.trim().to_ascii_lowercase().as_str() {
| "" => return Ok(default.answer()),
| "y" | "yes" => return Ok(true),
| "n" | "no" => return Ok(false),
| _ => {
writeln!(output, "{}", format_warning_text("Please answer yes or no.", style))
.map_err(CommandError::InteractiveInit)?;
}
}
}
}
fn prompt_default_path<R: BufRead, W: Write>(
input: &mut R, output: &mut W, default_question: &str, value_question: &str, default: PathBuf,
style: OutputStyle,
) -> Result<Option<PathBuf>, CommandError> {
let question = format!("{default_question} `{}`?", default.display());
if prompt_yes_no(input, output, &question, PromptDefault::Yes, style)? {
return Ok(None);
}
loop {
write!(output, "{value_question}: ").map_err(CommandError::InteractiveInit)?;
output.flush().map_err(CommandError::InteractiveInit)?;
let answer = read_prompt_line(input)?;
let path = answer.trim();
if !path.is_empty() {
return Ok(Some(PathBuf::from(path)));
}
writeln!(output, "{}", format_warning_text("Please enter a path.", style))
.map_err(CommandError::InteractiveInit)?;
}
}
fn read_prompt_line<R: BufRead>(input: &mut R) -> Result<String, CommandError> {
let mut answer = String::new();
let bytes = input.read_line(&mut answer).map_err(CommandError::InteractiveInit)?;
if bytes == 0 {
return Err(CommandError::InteractiveInitEof);
}
Ok(answer)
}
fn print_init_plan<W: Write>(
output: &mut W, request: &TopLevelInitRequest, config_path: &Path, lake_path: Option<&Path>,
style: OutputStyle,
) -> Result<(), CommandError> {
writeln!(output).map_err(CommandError::InteractiveInit)?;
writeln!(output, "Init plan:").map_err(CommandError::InteractiveInit)?;
if request.init_lake {
writeln!(
output,
" lake: {} ({})",
format_init_choice(true, style),
planned_lake_path(request, config_path, lake_path).display()
)
.map_err(CommandError::InteractiveInit)?;
} else {
writeln!(output, " lake: {}", format_init_choice(false, style))
.map_err(CommandError::InteractiveInit)?;
}
if request.init_frost {
writeln!(
output,
" frost: {} ({})",
format_init_choice(true, style),
planned_frost_path(request, config_path).display()
)
.map_err(CommandError::InteractiveInit)?;
} else {
writeln!(output, " frost: {}", format_init_choice(false, style))
.map_err(CommandError::InteractiveInit)?;
}
let skills = format_init_choice(request.init_skills, style);
writeln!(output, " skill wrappers: {skills}").map_err(CommandError::InteractiveInit)?;
let claude_skills = format_init_choice(request.init_claude_skills, style);
writeln!(output, " Claude skill links: {claude_skills}").map_err(CommandError::InteractiveInit)
}
fn format_init_choice(value: bool, style: OutputStyle) -> String {
if value { format_success_text("yes", style) } else { format_muted_text("no", style) }
}
fn planned_lake_path(
request: &TopLevelInitRequest, config_path: &Path, lake_path: Option<&Path>,
) -> PathBuf {
request
.lake
.clone()
.or_else(|| lake_path.map(Path::to_path_buf))
.unwrap_or_else(|| default_lake_path(config_path))
}
fn planned_frost_path(request: &TopLevelInitRequest, config_path: &Path) -> PathBuf {
request
.frost
.clone()
.or_else(|| configured_frost_path(config_path))
.unwrap_or_else(|| default_frost_path(config_path))
}
fn configured_or_default_frost_path(config_path: &Path) -> (&'static str, PathBuf) {
if let Some(path) = configured_frost_path(config_path) {
("Use configured frost path", path)
} else {
("Use default frost path", default_frost_path(config_path))
}
}
fn configured_frost_path(config_path: &Path) -> Option<PathBuf> {
SirnoConfig::from_file(config_path)
.ok()
.and_then(|config| config.frost.as_ref().map(|settings| settings.path.clone()))
}
fn run_top_level_init(
request: TopLevelInitRequest, config_path: &Path, lake_path: Option<&Path>,
) -> Result<ExitCode, CommandError> {
let mut initialized = false;
if request.init_lake {
run_lake_init(request.lake, config_path, lake_path)?;
initialized = true;
}
if request.init_frost {
if !request.init_lake {
ensure_config_for_top_level_frost(config_path, lake_path)?;
}
FrostCommand::Init { frost: request.frost }.run(config_path, lake_path)?;
initialized = true;
}
if request.init_skills {
run_skill_wrappers_init(config_path, request.init_claude_skills)?;
initialized = true;
}
if !initialized {
anstream::println!("{}", format_muted_text("nothing initialized", OutputStyle::Styled));
}
Ok(ExitCode::SUCCESS)
}
fn ensure_config_for_top_level_frost(
config_path: &Path, lake_path: Option<&Path>,
) -> Result<(), CommandError> {
if config_path.exists() {
return Ok(());
}
let config = SirnoConfig::new(
lake_path.map(Path::to_path_buf).unwrap_or_else(|| default_lake_path(config_path)),
);
config.write_new(config_path)?;
Ok(())
}
fn run_lake_init(
lake: Option<PathBuf>, config_path: &Path, lake_path: Option<&Path>,
) -> Result<ExitCode, CommandError> {
let result = SurfaceContext::from_cli_paths(config_path, lake_path)
.lake_init(LakeInitRequest { lake })?;
println!("{}", result.message);
Ok(ExitCode::SUCCESS)
}
impl EntryCommand {
fn run(self, config_path: &Path, lake_path: Option<&Path>) -> Result<ExitCode, CommandError> {
match self {
| EntryCommand::TopLevel(command) => command.run(config_path, lake_path),
| EntryCommand::Rename(args) => args.run(config_path, lake_path),
| EntryCommand::Path(args) => {
let records = entry_path_records(config_path, lake_path, &args)?;
print_path_records(&records, args.format.unwrap_or_default())?;
Ok(ExitCode::SUCCESS)
}
}
}
}
impl EntryRenameArgs {
fn run(self, config_path: &Path, lake_path: Option<&Path>) -> Result<ExitCode, CommandError> {
let old_id = EntryAddress::new(&self.old_id)?;
let new_id = EntryAddress::new(&self.new_id)?;
let result =
SurfaceContext::from_cli_paths(config_path, lake_path).entry_rename(old_id, new_id)?;
println!("{}", result.message);
println!("updated {} paths", result.updated_paths.len());
Ok(ExitCode::SUCCESS)
}
}
fn print_local_protection_result(result: &LocalProtectionResult, warning: &str) {
println!("{}", format_warning_text(warning, OutputStyle::Styled));
for path in &result.paths {
println!("{path}");
}
println!("{}", result.message);
}
impl TopLevelEntryCommand {
fn run(self, config_path: &Path, lake_path: Option<&Path>) -> Result<ExitCode, CommandError> {
match self {
| TopLevelEntryCommand::New { id, name, desc, structural, body } => {
let id = EntryAddress::new(&id)?;
let structural = structural
.into_iter()
.map(|target| StructuralTarget { field: target.field, target: target.target })
.collect();
let result = SurfaceContext::from_cli_paths(config_path, lake_path)
.entry_new(EntryNewRequest { id, name, desc, structural, body })?;
println!("{}", result.message);
Ok(ExitCode::SUCCESS)
}
| TopLevelEntryCommand::Freeze { id, fix_all, dry_run } => {
if fix_all {
let result = SurfaceContext::from_cli_paths(config_path, lake_path)
.entry_freeze_fix_all(dry_run)?;
print_local_protection_result(
&result,
"WARNING: freeze --fix-all rewrites Sirno local filesystem protection.",
);
return Ok(ExitCode::SUCCESS);
}
let Some(id) = id else {
return freeze::run(
config_path,
lake_path,
freeze::EntryFreezeTuiAction::Freeze,
);
};
if id == "tui" {
return freeze::run(
config_path,
lake_path,
freeze::EntryFreezeTuiAction::Freeze,
);
}
let id = EntryAddress::new(&id)?;
let result =
SurfaceContext::from_cli_paths(config_path, lake_path).entry_freeze(id)?;
println!("{}", result.message);
Ok(ExitCode::SUCCESS)
}
| TopLevelEntryCommand::Melt { id, unsafe_all, dry_run } => {
if unsafe_all {
let result = SurfaceContext::from_cli_paths(config_path, lake_path)
.entry_melt_unsafe_all(dry_run)?;
print_local_protection_result(
&result,
"DANGER: melt --unsafe-all makes Sirno-protected paths writable and deletable.",
);
return Ok(ExitCode::SUCCESS);
}
let Some(id) = id else {
return freeze::run(config_path, lake_path, freeze::EntryFreezeTuiAction::Melt);
};
if id == "tui" {
return freeze::run(config_path, lake_path, freeze::EntryFreezeTuiAction::Melt);
}
let id = EntryAddress::new(&id)?;
let result =
SurfaceContext::from_cli_paths(config_path, lake_path).entry_melt(id)?;
println!("{}", result.message);
Ok(ExitCode::SUCCESS)
}
| TopLevelEntryCommand::Query { terms, exact_terms, has, is, columns, format } => {
let request = QueryRequest {
terms,
exact_terms,
has,
is,
columns: match columns {
| None => QueryColumnSelection::Default,
| Some(None) => QueryColumnSelection::Options,
| Some(Some(columns)) => QueryColumnSelection::Selected(columns),
},
};
let run = SurfaceContext::from_cli_paths(config_path, lake_path)
.query_entries(request)?;
let format = format.unwrap_or_default();
let results = match run {
| QueryRun::ColumnOptions(columns) => {
print_query_column_options(&columns, format)?;
return Ok(ExitCode::SUCCESS);
}
| QueryRun::InvalidLake { report, .. } => {
print_entry_directory_report(&report);
return Ok(ExitCode::FAILURE);
}
| QueryRun::Results(results) => results,
};
print_query_results(&results, format)?;
Ok(ExitCode::SUCCESS)
}
| TopLevelEntryCommand::Rg { with_generated_footer, args } => {
let args = rg_args_to_strings(args)?;
let result = SurfaceContext::from_cli_paths(config_path, lake_path)
.entry_rg(RgRequest { with_generated_footer, args })?;
print!("{}", result.stdout);
eprint!("{}", result.stderr);
Ok(ExitCode::from(result.exit_code))
}
| TopLevelEntryCommand::Artifact { command } => command.run(config_path, lake_path),
| TopLevelEntryCommand::Witness { id, full } => {
run_witness_command(config_path, lake_path, &id, full)
}
}
}
}
impl LakeCommand {
fn run(
self, config_path: &Path, lake_path: Option<&Path>, frost_path: Option<&Path>,
) -> Result<ExitCode, CommandError> {
match self {
| LakeCommand::Init { .. } | LakeCommand::Move(_) if frost_path.is_some() => {
Err(CommandError::FrostPathRequiresCheck)
}
| LakeCommand::Init { lake } => run_lake_init(lake, config_path, lake_path),
| LakeCommand::Move(args) => args.run(config_path),
| LakeCommand::TopLevel(command) => command.run(config_path, lake_path, frost_path),
}
}
}
impl LakeMoveArgs {
fn run(self, config_path: &Path) -> Result<ExitCode, CommandError> {
let result = SurfaceContext::new(config_path.to_path_buf()).lake_move(self.lake)?;
println!("{}", result.message);
Ok(ExitCode::SUCCESS)
}
}
impl TopLevelLakeCommand {
fn run(
self, config_path: &Path, lake_path: Option<&Path>, frost_path: Option<&Path>,
) -> Result<ExitCode, CommandError> {
match self {
| TopLevelLakeCommand::Check { mode } => {
if lake_path.is_some() && frost_path.is_some() {
return Err(CommandError::LakePathWithFrostPath);
}
let mode = mode.unwrap_or(CheckModeArg::Review);
if lake_path.is_some() {
let result = SurfaceContext::from_cli_paths(config_path, lake_path)
.lake_check(mode.into())?;
print_lake_check_result(&result);
return if result.has_errors {
Ok(ExitCode::FAILURE)
} else {
Ok(ExitCode::SUCCESS)
};
}
let Some(frost_path) = frost_path else {
let result =
SurfaceContext::new(config_path.to_path_buf()).lake_check(mode.into())?;
print_lake_check_result(&result);
return if result.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() {
print_ok_path(frost.root());
return Ok(ExitCode::SUCCESS);
}
for diagnostic in report.diagnostics() {
let message = diagnostic.message();
print_check_diagnostic(diagnostic.severity.label(), &message);
}
print_check_summary(report.has_errors(), frost.root());
if report.has_errors() { Ok(ExitCode::FAILURE) } else { Ok(ExitCode::SUCCESS) }
}
| TopLevelLakeCommand::Render { .. } if frost_path.is_some() => {
Err(CommandError::FrostPathRequiresCheck)
}
| TopLevelLakeCommand::Render { command, dry, override_json } => match command {
| None => {
let result = SurfaceContext::from_cli_paths(config_path, lake_path)
.lake_render_with_override_json(dry, override_json.as_deref())?;
print_render_result(&result);
if result.ok { Ok(ExitCode::SUCCESS) } else { Ok(ExitCode::FAILURE) }
}
| Some(RenderCommand::Delete) => {
if dry {
return Err(CommandError::DryWithRenderSubcommand);
}
if override_json.is_some() {
return Err(CommandError::OverrideJsonWithRenderSubcommand);
}
let result = SurfaceContext::from_cli_paths(config_path, lake_path)
.lake_render_delete()?;
print_render_result(&result);
Ok(ExitCode::SUCCESS)
}
},
}
}
}
fn run_status_command(
config_path: &Path, lake_path: Option<&Path>,
) -> Result<ExitCode, CommandError> {
let result = SurfaceContext::from_cli_paths(config_path, lake_path).status()?;
print_status_result(&result);
if result.ok { Ok(ExitCode::SUCCESS) } else { Ok(ExitCode::FAILURE) }
}
impl TopLevelFrostCommand {
fn run(
self, config_path: &std::path::Path, lake_path: Option<&Path>,
) -> Result<ExitCode, CommandError> {
match self {
| TopLevelFrostCommand::Commit { unsafe_resolve_all } => {
let result = SurfaceContext::from_cli_paths(config_path, lake_path)
.frost_commit(unsafe_resolve_all)?;
println!("{}", result.message);
Ok(ExitCode::SUCCESS)
}
| TopLevelFrostCommand::Gc => {
let result = SurfaceContext::from_cli_paths(config_path, lake_path).frost_gc()?;
println!("{}", result.message);
Ok(ExitCode::SUCCESS)
}
| TopLevelFrostCommand::Defrost => CheckoutArgs::latest().run(config_path, lake_path),
| TopLevelFrostCommand::Checkout(args) => args.run(config_path, lake_path),
}
}
}
impl CheckoutArgs {
fn latest() -> Self {
Self { version: None, latest: true, unsafe_mutable: false }
}
fn run(self, config_path: &Path, lake_path: Option<&Path>) -> Result<ExitCode, CommandError> {
let result = SurfaceContext::from_cli_paths(config_path, lake_path).frost_checkout(
FrostCheckoutRequest {
version: self.version,
latest: self.latest,
unsafe_mutable: self.unsafe_mutable,
},
)?;
println!("{}", result.message);
Ok(ExitCode::SUCCESS)
}
}
impl FrostCommand {
fn run(
self, config_path: &std::path::Path, lake_path: Option<&Path>,
) -> Result<ExitCode, CommandError> {
match self {
| FrostCommand::Init { frost } => {
let result =
SurfaceContext::from_cli_paths(config_path, lake_path).frost_init(frost)?;
println!("{}", result.message);
Ok(ExitCode::SUCCESS)
}
| FrostCommand::Move(args) => args.run(config_path),
| FrostCommand::Snapshot(command) => command.run(config_path, lake_path),
}
}
}
impl FrostMoveArgs {
fn run(self, config_path: &Path) -> Result<ExitCode, CommandError> {
let result = SurfaceContext::new(config_path.to_path_buf()).frost_move(self.frost)?;
println!("{}", result.message);
Ok(ExitCode::SUCCESS)
}
}
impl UpstreamCommand {
fn run(
self, config_path: &std::path::Path, lake_path: Option<&Path>,
) -> Result<ExitCode, CommandError> {
let context = SurfaceContext::from_cli_paths(config_path, lake_path);
match self {
| Self::Add(args) => {
let result = context.upstream_add(args.into_request()?)?;
print_upstream_crystallize_report(&result);
Ok(ExitCode::SUCCESS)
}
| Self::Remove { domain } => {
let result = context.upstream_remove(entry_atom(&domain)?)?;
print_upstream_crystallize_report(&result);
Ok(ExitCode::SUCCESS)
}
| Self::Crystallize { domain, locked } => {
let result = context.upstream_crystallize(UpstreamCrystallizeRequest {
domains: upstream_domains(domain)?,
locked,
})?;
print_upstream_crystallize_report(&result);
Ok(ExitCode::SUCCESS)
}
| Self::Update { domain } => {
let result = context.upstream_update(upstream_domains(domain)?)?;
print_upstream_crystallize_report(&result);
Ok(ExitCode::SUCCESS)
}
| Self::Status { format } => {
let result = context.upstream_status()?;
match format.unwrap_or_default() {
| UpstreamOutputFormat::Json => print_json(&result)?,
| UpstreamOutputFormat::Human => print_upstream_status_report(&result),
}
if result.ok { Ok(ExitCode::SUCCESS) } else { Ok(ExitCode::FAILURE) }
}
}
}
}
impl UpstreamAddArgs {
fn into_request(self) -> Result<UpstreamAddRequest, CommandError> {
let mut settings = if let Some(branch) = self.branch {
UpstreamSettings::branch(self.git, branch)
} else if let Some(tag) = self.tag {
UpstreamSettings::tag(self.git, tag)
} else if let Some(rev) = self.rev {
UpstreamSettings::rev(self.git, rev)
} else {
unreachable!("clap requires one upstream selector")
};
if let Some(project) = self.project {
settings.project = project;
}
Ok(UpstreamAddRequest { domain: entry_atom(&self.domain)?, settings })
}
}
fn upstream_domains(domain: Option<String>) -> Result<Vec<EntryAtom>, CommandError> {
domain.map(|domain| entry_atom(&domain)).transpose().map(|domain| domain.into_iter().collect())
}
fn entry_atom(raw: &str) -> Result<EntryAtom, CommandError> {
Ok(EntryAtom::new(raw)?)
}
impl TideCommand {
fn run(
self, config_path: &std::path::Path, lake_path: Option<&Path>,
) -> Result<ExitCode, CommandError> {
match self {
| TideCommand::Status { show, by, format } => {
let context = SurfaceContext::from_cli_paths(config_path, lake_path);
let format = format.unwrap_or_default();
if show.includes_workitems() {
let statuses = context.tide_statuses(show)?;
print_tide_statuses(&statuses, by, format)?;
Ok(if statuses.iter().all(|status| status.resolved) {
ExitCode::SUCCESS
} else {
ExitCode::FAILURE
})
} else {
let statuses = context.tide_statuses(show)?;
print_tide_review_waves(&statuses, by, format)?;
Ok(if statuses.is_empty() { ExitCode::SUCCESS } else { ExitCode::FAILURE })
}
}
| TideCommand::Tui => tide::run(config_path, lake_path),
| TideCommand::Review(command) => command.run(config_path, lake_path),
| TideCommand::Reset => {
let result = SurfaceContext::from_cli_paths(config_path, lake_path).tide_reset()?;
println!("{}", result.message);
Ok(ExitCode::SUCCESS)
}
}
}
}
impl TideReviewCommand {
fn run(self, config_path: &Path, lake_path: Option<&Path>) -> Result<ExitCode, CommandError> {
match self {
| Self::Resolve(args) => args.run(config_path, lake_path),
| Self::Unresolve(args) => args.run(config_path, lake_path),
}
}
}
impl ResolveArgs {
fn run(self, config_path: &Path, lake_path: Option<&Path>) -> Result<ExitCode, CommandError> {
let request = if self.infer {
TideResolveRequest { infer: true, ..TideResolveRequest::default() }
} else if let Some(json) = self.json {
TideResolveRequest {
workitems: tide_workitems_from_json(&json)?,
..TideResolveRequest::default()
}
} else {
let selection = tide_selection_from_items(self.items);
TideResolveRequest {
neighbors: selection.neighbors,
workitems: selection.workitems,
..TideResolveRequest::default()
}
};
let result =
SurfaceContext::from_cli_paths(config_path, lake_path).tide_resolve(request)?;
println!("{}", result.message);
Ok(ExitCode::SUCCESS)
}
}
impl UnresolveArgs {
fn run(self, config_path: &Path, lake_path: Option<&Path>) -> Result<ExitCode, CommandError> {
let request = tide_selection_from_items(self.items);
let result =
SurfaceContext::from_cli_paths(config_path, lake_path).tide_unresolve(request)?;
println!("{}", result.message);
Ok(ExitCode::SUCCESS)
}
}
#[derive(Debug, Deserialize)]
#[serde(untagged)]
enum TideJsonWorkitems {
One(TideWorkitem),
Many(Vec<TideWorkitem>),
}
fn tide_workitems_from_json(source: &str) -> Result<Vec<TideWorkitem>, CommandError> {
Ok(match serde_json::from_str::<TideJsonWorkitems>(source)? {
| TideJsonWorkitems::One(workitem) => vec![workitem],
| TideJsonWorkitems::Many(workitems) => workitems,
})
}
fn tide_selection_from_items(items: Vec<TideItemSelector>) -> TideSelectionRequest {
let mut request = TideSelectionRequest::default();
for item in items {
match item {
| TideItemSelector::Neighbor(id) => request.neighbors.push(id),
| TideItemSelector::Workitem(workitem) => request.workitems.push(workitem),
}
}
request
}
fn print_tide_statuses(
statuses: &[TideStatus], grouping: TideStatusGrouping, format: TideOutputFormat,
) -> Result<(), CommandError> {
match format {
| TideOutputFormat::Json => {
print_json(statuses)?;
}
| TideOutputFormat::Human => {
anstream::print!(
"{}",
format_tide_statuses_grouped_with_style(statuses, grouping, OutputStyle::Styled)
);
}
}
Ok(())
}
fn print_tide_review_waves(
statuses: &[TideStatus], grouping: TideStatusGrouping, format: TideOutputFormat,
) -> Result<(), CommandError> {
match format {
| TideOutputFormat::Json => {
let entries = tide_review_entries_from_statuses(statuses);
print_json(&entries)?;
}
| TideOutputFormat::Human => {
print!("{}", format_tide_review_waves_grouped(statuses, grouping));
}
}
Ok(())
}
#[derive(Clone, Debug, PartialEq, Eq)]
struct TideReviewWave {
ripple: EntryAddress,
entries: Vec<EntryAddress>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
struct TideReviewEntryGroup {
entry: EntryAddress,
ripples: Vec<EntryAddress>,
}
fn format_tide_review_waves_grouped(
statuses: &[TideStatus], grouping: TideStatusGrouping,
) -> String {
match grouping {
| TideStatusGrouping::Wave => format_tide_review_waves(statuses),
| TideStatusGrouping::Entry => format_tide_review_entries(statuses),
}
}
fn format_tide_review_waves(statuses: &[TideStatus]) -> String {
let waves = tide_review_waves(statuses);
if waves.is_empty() {
return "tide: clear\n".to_owned();
}
let open_count = statuses.iter().filter(|status| !status.resolved).count();
let review_entry_count = tide_review_entries_from_statuses(statuses).len();
let rows = waves
.iter()
.flat_map(|wave| {
wave.entries.iter().enumerate().map(|(index, entry)| {
let ripple = if index == 0 { wave.ripple.to_string() } else { String::new() };
TideGroupedTableRow {
starts_group: index == 0,
cells: vec![ripple, entry.to_string()],
}
})
})
.collect::<Vec<_>>();
let mut output = format_tide_grouped_table(vec!["wave".to_owned(), "entry".to_owned()], rows);
output.push('\n');
output.push_str(&tide_summary_sentence(open_count, 0, waves.len(), review_entry_count));
output.push('\n');
output
}
fn format_tide_review_entries(statuses: &[TideStatus]) -> String {
let entries = tide_review_entry_groups(statuses);
if entries.is_empty() {
return "tide: clear\n".to_owned();
}
let waves = tide_review_waves(statuses);
let open_count = statuses.iter().filter(|status| !status.resolved).count();
let review_entry_count = entries.len();
let rows = entries
.iter()
.flat_map(|group| {
group.ripples.iter().enumerate().map(|(index, ripple)| {
let entry = if index == 0 { group.entry.to_string() } else { String::new() };
TideGroupedTableRow {
starts_group: index == 0,
cells: vec![entry, ripple.to_string()],
}
})
})
.collect::<Vec<_>>();
let mut output = format_tide_grouped_table(vec!["entry".to_owned(), "reason".to_owned()], rows);
output.push('\n');
output.push_str(&tide_summary_sentence(open_count, 0, waves.len(), review_entry_count));
output.push('\n');
output
}
fn format_tide_statuses_grouped_with_style(
statuses: &[TideStatus], grouping: TideStatusGrouping, style: OutputStyle,
) -> String {
match grouping {
| TideStatusGrouping::Wave => format_tide_statuses_with_style(statuses, style),
| TideStatusGrouping::Entry => format_tide_statuses_by_entry_with_style(statuses, style),
}
}
#[cfg(test)]
fn format_tide_statuses(statuses: &[TideStatus]) -> String {
format_tide_statuses_with_style(statuses, OutputStyle::Plain)
}
fn format_tide_statuses_with_style(statuses: &[TideStatus], style: OutputStyle) -> String {
let waves = tide_status_waves(statuses);
if waves.is_empty() {
return "tide: clear\n".to_owned();
}
let open_count = statuses.iter().filter(|status| !status.resolved).count();
let resolved_count = statuses.len() - open_count;
let review_entry_count = tide_review_entries_from_statuses(statuses).len();
let rows = waves
.iter()
.flat_map(|wave| {
wave.statuses.iter().enumerate().map(|(index, status)| {
let ripple = if index == 0 { wave.ripple.to_string() } else { String::new() };
TideGroupedTableRow {
starts_group: index == 0,
cells: vec![
ripple,
status.workitem.neighbor.to_string(),
tide_state_label(status).to_owned(),
status.workitem.field.clone(),
status.workitem.direction.to_string(),
tide_sources_label(status),
],
}
})
})
.collect::<Vec<_>>();
let mut output = format_tide_grouped_table_with_style(
vec![
"wave".to_owned(),
"entry".to_owned(),
"state".to_owned(),
"field".to_owned(),
"direction".to_owned(),
"sources".to_owned(),
],
rows,
style,
);
output.push('\n');
output.push_str(&tide_summary_sentence(
open_count,
resolved_count,
waves.len(),
review_entry_count,
));
output.push('\n');
output
}
#[cfg(test)]
fn format_tide_statuses_by_entry(statuses: &[TideStatus]) -> String {
format_tide_statuses_by_entry_with_style(statuses, OutputStyle::Plain)
}
fn format_tide_statuses_by_entry_with_style(statuses: &[TideStatus], style: OutputStyle) -> String {
let entries = tide_status_entry_groups(statuses);
if entries.is_empty() {
return "tide: clear\n".to_owned();
}
let waves = tide_status_waves(statuses);
let open_count = statuses.iter().filter(|status| !status.resolved).count();
let resolved_count = statuses.len() - open_count;
let review_entry_count = tide_review_entries_from_statuses(statuses).len();
let rows = entries
.iter()
.flat_map(|group| {
group.statuses.iter().enumerate().map(|(index, status)| {
let entry = if index == 0 { group.entry.to_string() } else { String::new() };
TideGroupedTableRow {
starts_group: index == 0,
cells: vec![
entry,
status.workitem.ripple.to_string(),
tide_state_label(status).to_owned(),
status.workitem.field.clone(),
status.workitem.direction.to_string(),
tide_sources_label(status),
],
}
})
})
.collect::<Vec<_>>();
let mut output = format_tide_grouped_table_with_style(
vec![
"entry".to_owned(),
"reason".to_owned(),
"state".to_owned(),
"field".to_owned(),
"direction".to_owned(),
"sources".to_owned(),
],
rows,
style,
);
output.push('\n');
output.push_str(&tide_summary_sentence(
open_count,
resolved_count,
waves.len(),
review_entry_count,
));
output.push('\n');
output
}
fn tide_summary_sentence(
open_count: usize, resolved_count: usize, wave_count: usize, review_entry_count: usize,
) -> String {
let resolved = if resolved_count == 0 {
String::new()
} else {
format!(
" and {resolved_count} resolved {}",
plural(resolved_count, "workitem", "workitems"),
)
};
format!(
"The tide has {open_count} open {}{resolved} in {wave_count} {}, \
with {review_entry_count} unique {}.",
plural(open_count, "workitem", "workitems"),
plural(wave_count, "wave", "waves"),
plural(review_entry_count, "review entry", "review entries"),
)
}
#[derive(Clone, Debug, PartialEq, Eq)]
struct TideGroupedTableRow {
cells: Vec<String>,
starts_group: bool,
}
fn format_tide_grouped_table(headers: Vec<String>, rows: Vec<TideGroupedTableRow>) -> String {
format_tide_grouped_table_with_style(headers, rows, OutputStyle::Plain)
}
fn format_tide_grouped_table_with_style(
headers: Vec<String>, rows: Vec<TideGroupedTableRow>, style: OutputStyle,
) -> String {
let group_start_rows = rows
.iter()
.enumerate()
.filter_map(|(index, row)| row.starts_group.then_some(index))
.filter(|index| *index > 0)
.collect::<Vec<_>>();
let rows = rows.into_iter().map(|row| row.cells).collect::<Vec<_>>();
let table = format_human_table_semantic_with_width(headers, rows, None, style);
strengthen_tide_group_separators(&table, &group_start_rows)
}
fn strengthen_tide_group_separators(table: &str, group_start_rows: &[usize]) -> String {
if group_start_rows.is_empty() {
return table.to_owned();
}
let mut lines = table.lines().map(str::to_owned).collect::<Vec<_>>();
for row_index in group_start_rows {
if let Some(separator) = lines.get_mut(tide_row_separator_index(*row_index)) {
*separator = heavy_table_separator(separator);
}
}
let mut output = lines.join("\n");
output.push('\n');
output
}
fn tide_row_separator_index(row_index: usize) -> usize {
2 * row_index + 2
}
fn heavy_table_separator(separator: &str) -> String {
let length = separator.chars().count();
separator
.chars()
.enumerate()
.map(|(index, character)| {
if index == 0 {
'╞'
} else if index + 1 == length {
'╡'
} else if character == '┼' {
'╪'
} else {
'═'
}
})
.collect()
}
#[derive(Clone, Debug, PartialEq, Eq)]
struct TideStatusWave<'a> {
ripple: EntryAddress,
statuses: Vec<&'a TideStatus>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
struct TideStatusEntryGroup<'a> {
entry: EntryAddress,
statuses: Vec<&'a TideStatus>,
}
fn tide_review_waves(statuses: &[TideStatus]) -> Vec<TideReviewWave> {
let mut entries_by_ripple = BTreeMap::<EntryAddress, BTreeSet<EntryAddress>>::new();
for status in statuses.iter().filter(|status| !status.resolved) {
entries_by_ripple
.entry(status.workitem.ripple.clone())
.or_default()
.insert(status.workitem.neighbor.clone());
}
entries_by_ripple
.into_iter()
.map(|(ripple, entries)| TideReviewWave { ripple, entries: entries.into_iter().collect() })
.collect()
}
fn tide_review_entry_groups(statuses: &[TideStatus]) -> Vec<TideReviewEntryGroup> {
let mut ripples_by_entry = BTreeMap::<EntryAddress, BTreeSet<EntryAddress>>::new();
for status in statuses.iter().filter(|status| !status.resolved) {
ripples_by_entry
.entry(status.workitem.neighbor.clone())
.or_default()
.insert(status.workitem.ripple.clone());
}
ripples_by_entry
.into_iter()
.map(|(entry, ripples)| TideReviewEntryGroup {
entry,
ripples: ripples.into_iter().collect(),
})
.collect()
}
fn tide_status_waves(statuses: &[TideStatus]) -> Vec<TideStatusWave<'_>> {
let mut statuses_by_ripple = BTreeMap::<EntryAddress, Vec<&TideStatus>>::new();
for status in statuses {
statuses_by_ripple.entry(status.workitem.ripple.clone()).or_default().push(status);
}
statuses_by_ripple
.into_iter()
.map(|(ripple, statuses)| TideStatusWave { ripple, statuses })
.collect()
}
fn tide_status_entry_groups(statuses: &[TideStatus]) -> Vec<TideStatusEntryGroup<'_>> {
let mut statuses_by_entry = BTreeMap::<EntryAddress, Vec<&TideStatus>>::new();
for status in statuses {
statuses_by_entry.entry(status.workitem.neighbor.clone()).or_default().push(status);
}
statuses_by_entry
.into_iter()
.map(|(entry, mut statuses)| {
statuses.sort_by(|left, right| left.workitem.cmp(&right.workitem));
TideStatusEntryGroup { entry, statuses }
})
.collect()
}
fn tide_review_entries_from_statuses(statuses: &[TideStatus]) -> Vec<EntryAddress> {
statuses
.iter()
.filter(|status| !status.resolved)
.map(|status| status.workitem.neighbor.clone())
.collect::<BTreeSet<_>>()
.into_iter()
.collect()
}
fn tide_state_label(status: &TideStatus) -> &'static str {
if status.resolved { "resolved" } else { "open" }
}
fn tide_sources_label(status: &TideStatus) -> String {
status
.sources
.iter()
.map(|source| match source {
| TideSource::Lake => "lake",
| TideSource::Frost => "frost",
})
.collect::<Vec<_>>()
.join(",")
}
fn plural<'a>(count: usize, singular: &'a str, plural: &'a str) -> &'a str {
if count == 1 { singular } else { plural }
}
impl ArtifactCommand {
fn run(self, config_path: &Path, lake_path: Option<&Path>) -> Result<ExitCode, CommandError> {
let context = SurfaceContext::from_cli_paths(config_path, lake_path);
match self {
| ArtifactCommand::List { id } => {
let id = EntryAddress::new(&id)?;
for artifact in context.entry_artifact_list(id)?.artifacts {
println!("{artifact}");
}
Ok(ExitCode::SUCCESS)
}
| ArtifactCommand::Add { id, source, artifact_path } => {
let id = EntryAddress::new(&id)?;
let result =
context.entry_artifact_add(ArtifactAddRequest { id, source, artifact_path })?;
println!("{}", result.message);
Ok(ExitCode::SUCCESS)
}
| ArtifactCommand::Rename { id, old_path, new_path } => {
let id = EntryAddress::new(&id)?;
let result = context.entry_artifact_rename(ArtifactRenameRequest {
id,
old_path,
new_path,
})?;
println!("{}", result.message);
Ok(ExitCode::SUCCESS)
}
| ArtifactCommand::Remove { id, artifact_path } => {
let id = EntryAddress::new(&id)?;
let result =
context.entry_artifact_remove(ArtifactRemoveRequest { id, artifact_path })?;
println!("{}", result.message);
Ok(ExitCode::SUCCESS)
}
}
}
}
impl UtilCommand {
fn run(
self, config_path: &Path, lake_path: Option<&Path>, frost_path: Option<&Path>,
) -> Result<ExitCode, CommandError> {
match self {
| UtilCommand::Config { command } => {
if lake_path.is_some() {
return Err(CommandError::ConfigRejectsLakePath);
}
if frost_path.is_some() {
return Err(CommandError::ConfigRejectsFrostPath);
}
command.unwrap_or(ConfigUtilityCommand::Tui).run(config_path)
}
| UtilCommand::Entry { command } => {
if frost_path.is_some() {
return Err(CommandError::FrostPathRequiresCheck);
}
command.unwrap_or(EntryUtilityCommand::Tui).run(config_path, lake_path)
}
| UtilCommand::Completion { shell } => {
if frost_path.is_some() {
return Err(CommandError::FrostPathRequiresCheck);
}
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)
}
| UtilCommand::Skills { command } => {
if lake_path.is_some() {
return Err(CommandError::SkillsRejectsLakePath);
}
if frost_path.is_some() {
return Err(CommandError::FrostPathRequiresCheck);
}
command.unwrap_or_default().run(config_path)
}
| UtilCommand::Mcp => {
if lake_path.is_some() {
return Err(CommandError::McpRejectsLakePath);
}
if frost_path.is_some() {
return Err(CommandError::McpRejectsFrostPath);
}
let runtime = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()
.map_err(CommandError::CreateMcpRuntime)?;
runtime
.block_on(crate::mcp::run_stdio(SurfaceContext::new(config_path.to_path_buf())))
.map_err(|error| CommandError::McpServer(error.to_string()))?;
Ok(ExitCode::SUCCESS)
}
}
}
}
impl ConfigUtilityCommand {
fn run(self, config_path: &Path) -> Result<ExitCode, CommandError> {
let context = SurfaceContext::new(config_path.to_path_buf());
match self {
| ConfigUtilityCommand::Tui => config::run(config_path),
| ConfigUtilityCommand::Check => {
let result = context.config_comments_check()?;
print_config_comment_result(&result);
if result.ok { Ok(ExitCode::SUCCESS) } else { Ok(ExitCode::FAILURE) }
}
| ConfigUtilityCommand::Fix => {
let result = context.config_comments_fix()?;
print_config_comment_result(&result);
Ok(ExitCode::SUCCESS)
}
}
}
}
impl EntryUtilityCommand {
fn run(self, config_path: &Path, lake_path: Option<&Path>) -> Result<ExitCode, CommandError> {
match self {
| EntryUtilityCommand::Tui => entry::run(config_path, lake_path),
}
}
}
impl SkillCommand {
fn run(self, config_path: &Path) -> Result<ExitCode, CommandError> {
let context = SurfaceContext::new(config_path.to_path_buf());
let result = match self {
| SkillCommand::Tui(args) => {
return skills::run(config_path, args.claude_skills);
}
| SkillCommand::Init(args) => {
context.skill_wrappers_init_with_claude(args.claude_skills)?
}
| SkillCommand::Check(args) => {
context.skill_wrappers_check_with_claude(args.claude_skills)?
}
| SkillCommand::List(args) => {
context.skill_wrappers_list_with_claude(args.claude_skills)?
}
};
Ok(print_skill_wrapper_result(result))
}
}
fn run_skill_wrappers_init(config_path: &Path, claude_skills: bool) -> Result<(), CommandError> {
let result = SurfaceContext::new(config_path.to_path_buf())
.skill_wrappers_init_with_claude(claude_skills)?;
print_skill_wrapper_result(result);
Ok(())
}
fn print_skill_wrapper_result(result: SkillWrapperResult) -> ExitCode {
anstream::print!("{}", format_skill_wrapper_table_for_terminal(&result.records));
anstream::println!("{}", result.message);
if result.ok { ExitCode::SUCCESS } else { ExitCode::FAILURE }
}
fn run_witness_command(
config_path: &Path, lake_path: Option<&Path>, raw_id: &str, full: bool,
) -> Result<ExitCode, CommandError> {
let id = EntryAddress::new(raw_id)?;
let records = SurfaceContext::from_cli_paths(config_path, lake_path).witness_records(&id)?;
if records.is_empty() {
println!("no witness found for {id}");
return Ok(ExitCode::FAILURE);
}
print_witness_records(&records, full);
Ok(ExitCode::SUCCESS)
}
fn entry_path_records(
config_path: &Path, lake_path: Option<&Path>, args: &EntryPathsArgs,
) -> Result<Vec<PathRecord>, CommandError> {
let request = EntryPathsRequest::new(
EntryAddress::new(&args.id)?,
path_selection_from_args(args),
args.absolute,
);
SurfaceContext::from_cli_paths(config_path, lake_path).entry_paths(request)
}
fn print_path_records(
records: &[PathRecord], format: PathOutputFormat,
) -> Result<(), CommandError> {
match format {
| PathOutputFormat::Json => print_json(records)?,
| PathOutputFormat::Human => print!("{}", format_path_table(records)),
| PathOutputFormat::Paths => {
for record in records {
println!("{}", record.path);
}
}
}
Ok(())
}
fn path_selection_from_args(args: &EntryPathsArgs) -> PathSelection {
let all = !args.show_entry && !args.show_artifact && !args.show_frost;
PathSelection::new(all || args.show_entry, all || args.show_artifact, all || args.show_frost)
}
#[cfg(test)]
mod tests;