mod output;
mod terminal;
use std::{io::Write, process::ExitCode};
use clap::{
ArgAction, Args, ColorChoice, CommandFactory, FromArgMatches, Parser, Subcommand, ValueEnum,
error::ErrorKind,
};
use clap_complete::{Shell, generate};
use pulldown_cmark::{
CodeBlockKind, Event, HeadingLevel, Options, Parser as MarkdownParser, Tag, TagEnd,
};
use regex::{Regex, RegexBuilder};
use serde::Serialize;
use crate::output::{
print_diagnostic_catalog, print_diagnostic_catalog_entry, print_entity_gets,
print_entity_inspections, print_inspection, print_qualifier_lint, print_qualifier_resolutions,
print_variable_lint, print_variable_resolutions, print_workspace_entity_list,
print_workspace_lint,
};
use rototo::{
Result, RototoError, SourceAuth, SourceOptions, StagedWorkspace, catalog, diagnostic_for_code,
inspect_workspace, lint_qualifier, lint_variable, lint_workspace,
model::{
PredicateActualTrace, PredicateResolutionTrace, QualifierLint, QualifierResolution,
QualifierResolutionTrace, VariableLint, VariableResolution, VariableResolutionTrace,
VariableSelectionTrace, WorkspaceLint,
},
resolve_qualifier, resolve_qualifier_with_trace, resolve_variable, resolve_variable_with_trace,
stage_workspace_source,
};
const DOCS_LIST_PAGE_WIDTH: usize = 30;
const DOCS_LIST_FALLBACK_WIDTH: usize = 100;
const DOCS_LIST_SEPARATOR: &str = " | ";
#[derive(Debug, Parser)]
#[command(
name = "rototo",
version,
about = "rototo is a control plane for runtime configuration in application code",
help_template = "{about}\n\n{usage-heading} {usage}{after-help}",
after_help = TOP_LEVEL_HELP,
args_conflicts_with_subcommands = false
)]
struct Cli {
#[arg(long, global = true, action = ArgAction::SetTrue)]
json: bool,
#[arg(
long,
global = true,
env = "ROTOTO_WORKSPACE_TOKEN",
hide_env_values = true,
value_name = "TOKEN"
)]
workspace_token: Option<String>,
#[arg(long, global = true, action = ArgAction::SetTrue)]
quiet: bool,
#[command(subcommand)]
command: Command,
}
#[derive(Debug, Subcommand)]
enum Command {
Inspect {
#[arg(value_name = "WORKSPACE_SOURCE")]
workspace_path: String,
#[command(flatten)]
selectors: EntityIdSelectors,
},
Lint {
#[arg(value_name = "WORKSPACE_SOURCE")]
workspace_path: String,
#[command(flatten)]
selectors: EntityIdSelectors,
#[arg(long, action = ArgAction::SetTrue)]
verbose: bool,
},
List {
#[arg(value_name = "WORKSPACE_SOURCE")]
workspace_path: String,
#[arg(short = 'v', long = "variable", action = ArgAction::SetTrue)]
variables: bool,
#[arg(short = 'q', long = "qualifier", action = ArgAction::SetTrue)]
qualifiers: bool,
},
Get {
#[arg(value_name = "WORKSPACE_SOURCE")]
workspace_path: String,
#[command(flatten)]
selectors: EntityIdSelectors,
},
Resolve {
#[arg(value_name = "WORKSPACE_SOURCE")]
workspace_path: String,
#[command(flatten)]
selectors: EntityIdSelectors,
#[arg(long = "env")]
env: Option<String>,
#[arg(short = 'c', long = "context", required = true)]
context: Vec<String>,
#[arg(long, action = ArgAction::SetTrue)]
verbose: bool,
},
#[command(visible_alias = "diag")]
Diagnostics {
#[command(subcommand)]
command: DiagnosticsCommand,
},
Docs {
#[arg(short = 'p', long, value_name = "PAGE_ID")]
page: Option<String>,
#[arg(short = 's', long = "search", value_name = "REGEX", num_args = 1..)]
search: Vec<String>,
},
#[command(visible_alias = "comp")]
Completions { shell: CompletionShell },
}
#[derive(Debug, Args)]
struct EntityIdSelectors {
#[arg(short = 'v', long = "variable", value_name = "ID")]
variables: Vec<String>,
#[arg(short = 'q', long = "qualifier", value_name = "ID")]
qualifiers: Vec<String>,
}
#[derive(Debug, Subcommand)]
enum DiagnosticsCommand {
List,
Get {
code: String,
},
}
const TOP_LEVEL_HELP: &str = r#"Start:
rototo docs
List bundled docs. Use this when you are learning rototo.
rototo docs -p quickstart
Read the shortest working example.
rototo docs -s 'refresh|last-known-good'
Search docs with a regex, like ripgrep.
Core Commands:
rototo inspect <workspace> [-v <variable> ...] [-q <qualifier> ...]
Explain workspace relationships, variables, qualifiers, environments, and rules.
rototo lint <workspace> [-v <variable> ...] [-q <qualifier> ...]
Validate workspace files, schemas, references, and custom lint. Use --verbose to see processing.
rototo list <workspace> [-v] [-q]
List variable and qualifier ids.
rototo get <workspace> [-v <variable> ...] [-q <qualifier> ...]
Print definitions.
rototo resolve <workspace> [-v <variable> ...] [-q <qualifier> ...] --env <env> -c <context>
Evaluate runtime config. Use --verbose to see why a value was selected.
Targets:
-v, --variable <id> Target a variable.
-q, --qualifier <id> Target a qualifier.
-c, --context <input> JSON object, @file, or path=value. Repeatable.
Workspace:
<workspace> can be a local path, file://, git+file://, git+https://, git+ssh://,
or an https:// archive. Use `rototo docs -p source-uri-reference` for exact URI forms.
Context:
Resolve commands accept one or more -c/--context values:
-c '{"user":{"tier":"premium"}}'
-c @context.json
-c user.tier=premium
Later context values override earlier ones.
Other Commands:
rototo diagnostics
Explain diagnostic codes.
rototo completions
Generate shell completions.
Global Options:
--json Emit machine-readable JSON.
--quiet Suppress success output from lint commands.
--workspace-token <token> Bearer token for https:// workspace archive downloads.
-h, --help Print help for rototo or a command.
-V, --version Print version.
Examples:
rototo inspect ./examples/basic
rototo lint ./examples/basic --verbose
rototo resolve ./examples/basic -v checkout-redesign --env prod -c user.tier=premium --verbose
More:
rototo <command> --help
rototo docs -p cli-reference
rototo docs -p source-uri-reference"#;
#[derive(Clone, Copy, Debug, ValueEnum)]
enum CompletionShell {
Bash,
Elvish,
Fish,
PowerShell,
Zsh,
}
impl From<CompletionShell> for Shell {
fn from(shell: CompletionShell) -> Self {
match shell {
CompletionShell::Bash => Shell::Bash,
CompletionShell::Elvish => Shell::Elvish,
CompletionShell::Fish => Shell::Fish,
CompletionShell::PowerShell => Shell::PowerShell,
CompletionShell::Zsh => Shell::Zsh,
}
}
}
#[tokio::main]
async fn main() -> ExitCode {
init_tracing();
match run().await {
Ok(status) => status,
Err(err) => {
eprintln!(
"{}: {err}",
terminal::stderr(terminal::Role::Error, "error")
);
ExitCode::FAILURE
}
}
}
async fn run() -> Result<ExitCode> {
let cli = parse_cli();
let source_options = source_options(&cli);
match cli.command {
Command::Inspect {
workspace_path,
selectors,
} => {
let workspace = verb_workspace(workspace_path, &source_options).await?;
let inspection = inspect_workspace(workspace.path()).await?;
if selectors.variables.is_empty() && selectors.qualifiers.is_empty() {
print_inspection(&inspection, cli.json).await?;
} else {
print_entity_inspections(
&inspection,
&selectors.variables,
&selectors.qualifiers,
cli.json,
)
.await?;
}
Ok(ExitCode::SUCCESS)
}
Command::Lint {
workspace_path,
selectors,
verbose,
} => {
let (workspace_source, workspace) =
verb_workspace_with_source(workspace_path, &source_options).await?;
let trace = verbose.then(|| workspace_trace(&workspace_source, &workspace));
print_lint_targets(
workspace.path(),
&selectors,
cli.json,
cli.quiet,
trace.as_ref(),
)
.await
}
Command::List {
workspace_path,
variables,
qualifiers,
} => {
let workspace = verb_workspace(workspace_path, &source_options).await?;
let inspection = inspect_workspace(workspace.path()).await?;
let show_variables = variables || !qualifiers;
let show_qualifiers = qualifiers || !variables;
print_workspace_entity_list(&inspection, show_variables, show_qualifiers, cli.json)?;
Ok(ExitCode::SUCCESS)
}
Command::Get {
workspace_path,
selectors,
} => {
require_entity_selectors(&selectors, "get")?;
let workspace = verb_workspace(workspace_path, &source_options).await?;
let inspection = inspect_workspace(workspace.path()).await?;
print_entity_gets(
&inspection,
&selectors.variables,
&selectors.qualifiers,
cli.json,
)
.await?;
Ok(ExitCode::SUCCESS)
}
Command::Resolve {
workspace_path,
selectors,
env,
context,
verbose,
} => {
require_entity_selectors(&selectors, "resolve")?;
let (workspace_source, workspace) =
verb_workspace_with_source(workspace_path, &source_options).await?;
let context_value = parse_context(&context).await?;
let trace = verbose.then(|| workspace_trace(&workspace_source, &workspace));
print_resolve_targets(
workspace.path(),
&selectors,
env.as_deref(),
&context,
&context_value,
cli.json,
trace.as_ref(),
)
.await
}
Command::Diagnostics {
command: DiagnosticsCommand::List,
} => {
let catalog = catalog();
print_diagnostic_catalog(&catalog, cli.json)?;
Ok(ExitCode::SUCCESS)
}
Command::Diagnostics {
command: DiagnosticsCommand::Get { code },
} => {
let catalog = catalog();
let diagnostic = diagnostic_for_code(&catalog, &code)?;
print_diagnostic_catalog_entry(diagnostic, cli.json)?;
Ok(ExitCode::SUCCESS)
}
Command::Docs { page, search } => print_docs(page.as_deref(), &search, cli.json),
Command::Completions { shell } => {
let mut command = cli_command();
let name = command.get_name().to_owned();
generate(
Shell::from(shell),
&mut command,
name,
&mut std::io::stdout(),
);
Ok(ExitCode::SUCCESS)
}
}
}
fn parse_cli() -> Cli {
match cli_command().try_get_matches() {
Ok(matches) => Cli::from_arg_matches(&matches).unwrap_or_else(|err| err.exit()),
Err(err) if err.kind() == ErrorKind::DisplayHelp => {
let help = style_help_text(&err.to_string());
let _ = std::io::stdout().write_all(help.as_bytes());
std::process::exit(err.exit_code());
}
Err(err) => err.exit(),
}
}
fn cli_command() -> clap::Command {
Cli::command().color(help_color())
}
fn style_help_text(help: &str) -> String {
let mut styled = String::with_capacity(help.len());
let mut section = HelpSection::Other;
for line in help.split_inclusive('\n') {
if let Some(content) = line.strip_suffix('\n') {
styled.push_str(&style_help_line(content, section));
styled.push('\n');
section = help_section_after_line(content, section);
} else {
styled.push_str(&style_help_line(line, section));
section = help_section_after_line(line, section);
}
}
styled
}
#[derive(Clone, Copy)]
enum HelpSection {
Other,
Commands,
Arguments,
Options,
Start,
CoreCommands,
Targets,
Workspace,
Context,
OtherCommands,
GlobalOptions,
Examples,
More,
}
#[derive(Clone, Copy)]
enum HelpFlagTarget {
Variable,
Qualifier,
Environment,
Context,
Other,
}
fn help_section_after_line(line: &str, current: HelpSection) -> HelpSection {
let Some(heading) = help_heading(line) else {
return current;
};
match heading {
"Commands" => HelpSection::Commands,
"Arguments" => HelpSection::Arguments,
"Options" => HelpSection::Options,
"Start" => HelpSection::Start,
"Core Commands" => HelpSection::CoreCommands,
"Targets" => HelpSection::Targets,
"Workspace" => HelpSection::Workspace,
"Context" => HelpSection::Context,
"Other Commands" => HelpSection::OtherCommands,
"Global Options" => HelpSection::GlobalOptions,
"Examples" => HelpSection::Examples,
"More" => HelpSection::More,
_ => HelpSection::Other,
}
}
fn help_heading(line: &str) -> Option<&str> {
line.strip_suffix(':')
.filter(|heading| !heading.is_empty() && !line.starts_with(' '))
}
fn style_help_line(line: &str, section: HelpSection) -> String {
if let Some(rest) = line.strip_prefix("Usage: ") {
return format!(
"{}: {}",
terminal::stdout_bold(terminal::Role::Fg, "Usage"),
style_help_syntax(rest)
);
}
if let Some(heading) = help_heading(line) {
return format!("{}:", terminal::stdout_bold(terminal::Role::Fg, heading));
}
match section {
HelpSection::Commands => style_command_help_line(line),
HelpSection::Arguments | HelpSection::Options => style_aligned_syntax_help_line(line),
HelpSection::Start
| HelpSection::CoreCommands
| HelpSection::OtherCommands
| HelpSection::Examples
| HelpSection::More => style_command_block_help_line(line),
HelpSection::Targets | HelpSection::GlobalOptions => style_aligned_syntax_help_line(line),
HelpSection::Workspace => style_help_prose(line),
HelpSection::Context => {
if is_context_example_line(line) {
style_indented_syntax_help_line(line)
} else {
style_help_prose(line)
}
}
HelpSection::Other => style_help_prose(line),
}
}
fn style_command_help_line(line: &str) -> String {
let (indent, body) = split_indent(line);
let Some((name, rest)) = split_first_token(body) else {
return line.to_owned();
};
if is_help_command_token(name) {
format!(
"{indent}{}{}",
terminal::stdout(terminal::Role::Info, name),
style_help_prose(rest)
)
} else {
style_help_prose(line)
}
}
fn style_aligned_syntax_help_line(line: &str) -> String {
let (indent, body) = split_indent(line);
let Some((syntax, description)) = split_aligned_description(body) else {
return format!("{indent}{}", style_help_syntax(body));
};
format!(
"{indent}{}{}",
style_help_syntax(syntax),
style_help_prose(description)
)
}
fn style_command_block_help_line(line: &str) -> String {
let (indent, body) = split_indent(line);
if indent.len() <= 2
&& split_first_token(body)
.map(|(token, _)| {
help_token_core(token)
.map(is_help_command_token)
.unwrap_or(false)
})
.unwrap_or(false)
{
return format!("{indent}{}", style_help_syntax(body));
}
style_help_prose(line)
}
fn style_indented_syntax_help_line(line: &str) -> String {
let (indent, body) = split_indent(line);
format!("{indent}{}", style_help_syntax(body))
}
fn is_context_example_line(line: &str) -> bool {
let (indent, body) = split_indent(line);
indent.len() >= 4 && body.starts_with("-c ")
}
fn style_help_syntax(body: &str) -> String {
let mut styled = String::with_capacity(body.len());
let mut index = 0;
let mut last_flag = None;
while index < body.len() {
let rest = &body[index..];
let ch = rest.chars().next().expect("index is in bounds");
if ch.is_whitespace() {
styled.push(ch);
index += ch.len_utf8();
continue;
}
if ch == '`'
&& let Some(end) = rest[1..].find('`')
{
let token = &rest[..end + 2];
styled.push_str(&terminal::stdout(terminal::Role::Info, token));
index += token.len();
last_flag = None;
continue;
}
if ch == '<'
&& let Some(end) = rest.find('>')
{
let token = &rest[..=end];
styled.push_str(&terminal::stdout(
help_placeholder_role(token, last_flag),
token,
));
index += token.len();
last_flag = None;
continue;
}
if ch == '['
&& let Some(end) = rest.find(']')
{
styled.push_str(&style_optional_syntax_group(&rest[..=end]));
index += end + 1;
last_flag = None;
continue;
}
let end = rest
.char_indices()
.find_map(|(offset, ch)| ch.is_whitespace().then_some(offset))
.unwrap_or(rest.len());
let token = &rest[..end];
let core = help_token_core(token);
if let Some(core) = core {
if is_help_flag_token(core) {
styled.push_str(&style_help_token_core(token, core, terminal::Role::Info));
last_flag = Some(help_flag_target(core));
} else if is_help_command_token(core) {
styled.push_str(&style_help_token_core(token, core, terminal::Role::Info));
last_flag = None;
} else if is_help_source_token(core)
|| is_help_context_token(core)
|| is_help_environment_token(core)
{
styled.push_str(&style_help_token_core(token, core, terminal::Role::Dim));
last_flag = None;
} else {
styled.push_str(token);
last_flag = None;
}
} else {
styled.push_str(token);
last_flag = None;
}
index += token.len();
}
styled
}
fn style_help_token_core(token: &str, core: &str, role: terminal::Role) -> String {
let Some(start) = token.find(core) else {
return token.to_owned();
};
let end = start + core.len();
format!(
"{}{}{}",
&token[..start],
terminal::stdout(role, core),
&token[end..]
)
}
fn style_optional_syntax_group(token: &str) -> String {
let Some(inner) = token
.strip_prefix('[')
.and_then(|text| text.strip_suffix(']'))
else {
return terminal::stdout(terminal::Role::Dim, token);
};
format!(
"{}{}{}",
terminal::stdout(terminal::Role::Dim, "["),
style_help_syntax(inner),
terminal::stdout(terminal::Role::Dim, "]")
)
}
fn style_help_prose(line: &str) -> String {
let mut styled = String::with_capacity(line.len());
let mut index = 0;
while index < line.len() {
let rest = &line[index..];
let ch = rest.chars().next().expect("index is in bounds");
if ch == '`'
&& let Some(end) = rest[1..].find('`')
{
let token = &rest[..end + 2];
styled.push_str(&terminal::stdout(terminal::Role::Info, token));
index += token.len();
continue;
}
if ch == '['
&& let Some(end) = rest.find(']')
{
let token = &rest[..=end];
styled.push_str(&terminal::stdout(terminal::Role::Dim, token));
index += token.len();
continue;
}
if ch.is_whitespace() {
styled.push(ch);
index += ch.len_utf8();
continue;
}
let end = rest
.char_indices()
.find_map(|(offset, ch)| ch.is_whitespace().then_some(offset))
.unwrap_or(rest.len());
let token = &rest[..end];
let core = help_token_core(token);
if let Some(core) = core {
if is_help_flag_token(core) {
styled.push_str(&style_help_token_core(token, core, terminal::Role::Info));
} else if is_help_source_token(core)
|| is_help_context_token(core)
|| is_help_environment_token(core)
{
styled.push_str(&style_help_token_core(token, core, terminal::Role::Dim));
} else {
styled.push_str(token);
}
} else {
styled.push_str(token);
}
index += token.len();
}
styled
}
fn split_indent(line: &str) -> (&str, &str) {
let body_start = line
.char_indices()
.find_map(|(index, ch)| (!ch.is_whitespace()).then_some(index))
.unwrap_or(line.len());
line.split_at(body_start)
}
fn split_first_token(body: &str) -> Option<(&str, &str)> {
let end = body
.char_indices()
.find_map(|(offset, ch)| ch.is_whitespace().then_some(offset))
.unwrap_or(body.len());
(end > 0).then(|| body.split_at(end))
}
fn split_aligned_description(body: &str) -> Option<(&str, &str)> {
let mut seen_nonspace = false;
let mut spaces_start = None;
let mut spaces = 0;
for (index, ch) in body.char_indices() {
if ch.is_whitespace() {
if seen_nonspace {
spaces_start.get_or_insert(index);
spaces += 1;
}
continue;
}
if spaces >= 2 {
let start = spaces_start.expect("space run has a start");
return Some((&body[..start], &body[start..]));
}
seen_nonspace = true;
spaces_start = None;
spaces = 0;
}
None
}
fn help_token_core(token: &str) -> Option<&str> {
let core = token.trim_matches(|ch: char| matches!(ch, ',' | ':' | ';' | '(' | ')'));
(!core.is_empty()).then_some(core)
}
fn help_placeholder_role(token: &str, last_flag: Option<HelpFlagTarget>) -> terminal::Role {
match last_flag {
Some(HelpFlagTarget::Variable) => return terminal::Role::Info,
Some(HelpFlagTarget::Qualifier) => return terminal::Role::Clay,
Some(HelpFlagTarget::Environment) => return terminal::Role::Ok,
Some(HelpFlagTarget::Context) => return terminal::Role::Dim,
Some(HelpFlagTarget::Other) | None => {}
}
let name = token
.trim_start_matches('<')
.trim_end_matches('>')
.to_ascii_lowercase()
.replace('_', "-");
match name.as_str() {
"variable" | "variables" => terminal::Role::Info,
"qualifier" | "qualifiers" => terminal::Role::Clay,
"env" | "environment" => terminal::Role::Ok,
"context" => terminal::Role::Dim,
_ => terminal::Role::Dim,
}
}
fn help_flag_target(token: &str) -> HelpFlagTarget {
match token {
"-v" | "--variable" | "-v/--variable" => HelpFlagTarget::Variable,
"-q" | "--qualifier" | "-q/--qualifier" => HelpFlagTarget::Qualifier,
"--env" => HelpFlagTarget::Environment,
"-c" | "--context" | "-c/--context" => HelpFlagTarget::Context,
_ => HelpFlagTarget::Other,
}
}
fn is_help_command_token(token: &str) -> bool {
matches!(
token,
"rototo"
| "inspect"
| "lint"
| "list"
| "get"
| "resolve"
| "diagnostics"
| "docs"
| "completions"
| "help"
| "diag"
| "comp"
)
}
fn is_help_flag_token(token: &str) -> bool {
token.starts_with('-') || token.contains("/--")
}
fn is_help_source_token(token: &str) -> bool {
token.starts_with("./")
|| token.starts_with('/')
|| token.starts_with("file://")
|| token.starts_with("git+")
|| token.starts_with("https://")
}
fn is_help_context_token(token: &str) -> bool {
token.starts_with('@') || token.contains('=')
}
fn is_help_environment_token(token: &str) -> bool {
token
.chars()
.all(|ch| ch.is_ascii_uppercase() || ch.is_ascii_digit() || ch == '_')
&& token.contains('_')
}
fn help_color() -> ColorChoice {
if std::env::var_os("NO_COLOR").is_some() {
return ColorChoice::Never;
}
if std::env::var_os("CLICOLOR").as_deref() == Some(std::ffi::OsStr::new("0")) {
return ColorChoice::Never;
}
if let Some(force) = std::env::var_os("CLICOLOR_FORCE")
&& force.to_string_lossy() != "0"
{
return ColorChoice::Always;
}
ColorChoice::Auto
}
#[derive(Serialize)]
struct EntityLintJson<'a> {
workspace: String,
variables: Vec<EntityLintItemJson<'a>>,
qualifiers: Vec<EntityLintItemJson<'a>>,
}
#[derive(Serialize)]
struct EntityLintItemJson<'a> {
id: &'a str,
diagnostics: &'a [rototo::diagnostics::Diagnostic],
}
#[derive(Serialize)]
struct EntityResolutionJson<'a> {
workspace: String,
variables: &'a [VariableResolution],
qualifiers: &'a [QualifierResolution],
}
struct WorkspaceTrace {
source: String,
staged_path: String,
staged_kind: &'static str,
}
async fn verb_workspace(
workspace_path: String,
source_options: &SourceOptions,
) -> Result<StagedWorkspace> {
let (_source, workspace) = verb_workspace_with_source(workspace_path, source_options).await?;
Ok(workspace)
}
async fn verb_workspace_with_source(
workspace_path: String,
source_options: &SourceOptions,
) -> Result<(String, StagedWorkspace)> {
let workspace = stage_workspace_source(workspace_path.clone(), source_options).await?;
Ok((workspace_path, workspace))
}
fn workspace_trace(source: &str, workspace: &StagedWorkspace) -> WorkspaceTrace {
WorkspaceTrace {
source: source.to_owned(),
staged_path: workspace.path().display().to_string(),
staged_kind: if workspace.is_temporary() {
"temporary"
} else {
"local"
},
}
}
fn require_entity_selectors(selectors: &EntityIdSelectors, command: &str) -> Result<()> {
if selectors.variables.is_empty() && selectors.qualifiers.is_empty() {
return Err(RototoError::new(format!(
"`rototo {command}` requires at least one -v/--variable or -q/--qualifier"
)));
}
Ok(())
}
async fn print_lint_targets(
workspace: &std::path::Path,
selectors: &EntityIdSelectors,
json: bool,
quiet: bool,
trace: Option<&WorkspaceTrace>,
) -> Result<ExitCode> {
if selectors.variables.is_empty() && selectors.qualifiers.is_empty() {
let lint = lint_workspace(workspace).await?;
let passed = lint.diagnostics.is_empty();
if let Some(trace) = trace
&& !json
{
print_workspace_lint_trace(trace, &lint);
}
print_workspace_lint(&lint, json, quiet)?;
return Ok(if passed {
ExitCode::SUCCESS
} else {
ExitCode::FAILURE
});
}
let mut variable_lints = Vec::new();
for id in &selectors.variables {
variable_lints.push(lint_variable(workspace, id).await?);
}
let mut qualifier_lints = Vec::new();
for id in &selectors.qualifiers {
qualifier_lints.push(lint_qualifier(workspace, id).await?);
}
let passed = variable_lints
.iter()
.all(|lint| lint.diagnostics.is_empty())
&& qualifier_lints
.iter()
.all(|lint| lint.diagnostics.is_empty());
if json {
println!(
"{}",
serde_json::to_string_pretty(&EntityLintJson {
workspace: workspace.display().to_string(),
variables: variable_lints
.iter()
.map(|lint| EntityLintItemJson {
id: &lint.id,
diagnostics: &lint.diagnostics,
})
.collect(),
qualifiers: qualifier_lints
.iter()
.map(|lint| EntityLintItemJson {
id: &lint.id,
diagnostics: &lint.diagnostics,
})
.collect(),
})
.map_err(|err| RototoError::new(err.to_string()))?
);
} else {
if let Some(trace) = trace {
print_entity_lint_trace(trace, selectors, &variable_lints, &qualifier_lints);
}
for lint in &variable_lints {
print_variable_lint(lint, false, quiet)?;
}
for lint in &qualifier_lints {
print_qualifier_lint(lint, false, quiet)?;
}
}
Ok(if passed {
ExitCode::SUCCESS
} else {
ExitCode::FAILURE
})
}
async fn print_resolve_targets(
workspace: &std::path::Path,
selectors: &EntityIdSelectors,
env: Option<&str>,
context_inputs: &[String],
context: &serde_json::Value,
json: bool,
trace: Option<&WorkspaceTrace>,
) -> Result<ExitCode> {
let explain = trace.is_some() && !json;
let mut variable_resolutions = Vec::new();
let mut variable_traces = Vec::new();
if !selectors.variables.is_empty() {
let env =
env.ok_or_else(|| RototoError::new("resolving variables requires --env <env>"))?;
for id in &selectors.variables {
if explain {
let (resolution, trace) =
resolve_variable_with_trace(workspace, id, env, context).await?;
variable_resolutions.push(resolution);
variable_traces.push(trace);
} else {
variable_resolutions.push(resolve_variable(workspace, id, env, context).await?);
}
}
}
let mut qualifier_resolutions = Vec::new();
let mut qualifier_traces = Vec::new();
for id in &selectors.qualifiers {
if explain {
let (resolution, trace) = resolve_qualifier_with_trace(workspace, id, context).await?;
qualifier_resolutions.push(resolution);
qualifier_traces.push(trace);
} else {
qualifier_resolutions.push(resolve_qualifier(workspace, id, context).await?);
}
}
if json {
println!(
"{}",
serde_json::to_string_pretty(&EntityResolutionJson {
workspace: workspace.display().to_string(),
variables: &variable_resolutions,
qualifiers: &qualifier_resolutions,
})
.map_err(|err| RototoError::new(err.to_string()))?
);
} else {
if let Some(trace) = trace {
print_resolve_trace(
trace,
selectors,
env,
context_inputs,
context,
&variable_traces,
&qualifier_traces,
)?;
}
print_variable_resolutions(workspace, &variable_resolutions, false)?;
print_qualifier_resolutions(workspace, &qualifier_resolutions, false)?;
}
Ok(ExitCode::SUCCESS)
}
fn print_workspace_lint_trace(trace: &WorkspaceTrace, lint: &WorkspaceLint) {
print_trace_workspace("lint trace", trace);
println!(
" {}: workspace",
terminal::stdout(terminal::Role::Fg, "targets")
);
println!(
" {}: {}",
terminal::stdout(terminal::Role::Fg, "workspace lint"),
diagnostic_status(lint.diagnostics.len())
);
println!();
}
fn print_entity_lint_trace(
trace: &WorkspaceTrace,
selectors: &EntityIdSelectors,
variable_lints: &[VariableLint],
qualifier_lints: &[QualifierLint],
) {
print_trace_workspace("lint trace", trace);
print_trace_targets(selectors);
for lint in variable_lints {
println!(
" {} {}: {}",
terminal::stdout(terminal::Role::Fg, "variable"),
terminal::stdout(terminal::Role::Info, &lint.id),
diagnostic_status(lint.diagnostics.len())
);
}
for lint in qualifier_lints {
println!(
" {} {}: {}",
terminal::stdout(terminal::Role::Fg, "qualifier"),
terminal::stdout(terminal::Role::Clay, &lint.id),
diagnostic_status(lint.diagnostics.len())
);
}
println!();
}
fn print_resolve_trace(
trace: &WorkspaceTrace,
selectors: &EntityIdSelectors,
env: Option<&str>,
context_inputs: &[String],
context: &serde_json::Value,
variable_traces: &[VariableResolutionTrace],
qualifier_traces: &[QualifierResolutionTrace],
) -> Result<()> {
print_trace_workspace("resolve trace", trace);
print_trace_targets(selectors);
if !selectors.variables.is_empty() {
println!(
" {}: {}",
terminal::stdout(terminal::Role::Fg, "environment"),
terminal::stdout(terminal::Role::Ok, env.unwrap_or("<missing>"))
);
}
println!(
" {}:",
terminal::stdout(terminal::Role::Fg, "context inputs")
);
for input in context_inputs {
println!(" {}", terminal::stdout(terminal::Role::Dim, input));
}
println!(
" {}:",
terminal::stdout(terminal::Role::Fg, "merged context")
);
let context =
serde_json::to_string_pretty(context).map_err(|err| RototoError::new(err.to_string()))?;
for line in context.lines() {
println!(" {}", terminal::stdout(terminal::Role::Dim, line));
}
if !variable_traces.is_empty() || !qualifier_traces.is_empty() {
println!();
}
for trace in variable_traces {
print_variable_resolution_trace(trace)?;
}
for trace in qualifier_traces {
print_qualifier_resolution_trace(trace, 1)?;
}
println!();
Ok(())
}
fn print_variable_resolution_trace(trace: &VariableResolutionTrace) -> Result<()> {
println!(
" {} {}:",
terminal::stdout(terminal::Role::Fg, "variable"),
terminal::stdout(terminal::Role::Info, &trace.id)
);
println!(
" {}: {}",
terminal::stdout(terminal::Role::Fg, "requested env"),
terminal::stdout(terminal::Role::Ok, &trace.requested_environment)
);
let environment_block = if trace.environment_fallback {
format!(
"{} ({})",
terminal::stdout(terminal::Role::Ok, &trace.environment_block),
terminal::stdout(terminal::Role::Dim, "fallback")
)
} else {
terminal::stdout(terminal::Role::Ok, &trace.environment_block)
};
println!(
" {}: {}",
terminal::stdout(terminal::Role::Fg, "env block"),
environment_block
);
if trace.rules.is_empty() {
println!(
" {}: {}",
terminal::stdout(terminal::Role::Fg, "rules"),
terminal::stdout(terminal::Role::Dim, "none")
);
}
for rule in &trace.rules {
let outcome = if rule.selected {
format!(
"{}, selects {}",
bool_text(rule.qualifier_value),
terminal::stdout(
terminal::Role::Fg,
rule.value.as_deref().unwrap_or("<missing value>")
)
)
} else if rule.qualifier_value {
format!(
"{}, {}",
bool_text(rule.qualifier_value),
terminal::stdout(terminal::Role::Dim, "no value")
)
} else {
format!(
"{}, {}",
bool_text(rule.qualifier_value),
terminal::stdout(terminal::Role::Dim, "skipped")
)
};
println!(
" {} {}: {} -> {}",
terminal::stdout(terminal::Role::Fg, "rule"),
rule.index,
terminal::stdout(terminal::Role::Clay, &rule.qualifier),
outcome
);
if let Some(description) = &rule.description {
println!(
" {}: {}",
terminal::stdout(terminal::Role::Fg, "definition"),
terminal::stdout(terminal::Role::Dim, description)
);
}
print_qualifier_resolution_trace(&rule.qualifier_trace, 3)?;
}
if let Some(default_value) = &trace.default_value {
println!(
" {}: {}",
terminal::stdout(terminal::Role::Fg, "default"),
terminal::stdout(terminal::Role::Fg, default_value)
);
}
println!(
" {}: {} ({})",
terminal::stdout(terminal::Role::Fg, "selected"),
terminal::stdout(terminal::Role::Fg, &trace.selected_value),
variable_selection_reason(&trace.selected_by)
);
Ok(())
}
fn print_qualifier_resolution_trace(trace: &QualifierResolutionTrace, indent: usize) -> Result<()> {
let pad = " ".repeat(indent);
let cached = if trace.cached {
format!(" {}", terminal::stdout(terminal::Role::Dim, "(cached)"))
} else {
String::new()
};
println!(
"{pad}{} {}{}:",
terminal::stdout(terminal::Role::Fg, "qualifier"),
terminal::stdout(terminal::Role::Clay, &trace.id),
cached
);
if let Some(description) = &trace.description {
println!(
"{pad} {}: {}",
terminal::stdout(terminal::Role::Fg, "definition"),
terminal::stdout(terminal::Role::Dim, description)
);
}
if trace.predicates.is_empty() {
println!(
"{pad} {}: {}",
terminal::stdout(terminal::Role::Fg, "predicates"),
terminal::stdout(terminal::Role::Dim, "none")
);
}
for predicate in &trace.predicates {
print_predicate_resolution_trace(predicate, indent + 1)?;
}
println!(
"{pad} {}: {}",
terminal::stdout(terminal::Role::Fg, "result"),
bool_text(trace.value)
);
Ok(())
}
fn print_predicate_resolution_trace(trace: &PredicateResolutionTrace, indent: usize) -> Result<()> {
let pad = " ".repeat(indent);
println!(
"{pad}{} {} -> {}",
terminal::stdout(terminal::Role::Dim, format!("predicate {}", trace.index)),
predicate_expression(trace)?,
bool_text(trace.value)
);
if let Some(description) = &trace.description {
println!(
"{pad} {}: {}",
terminal::stdout(terminal::Role::Fg, "definition"),
terminal::stdout(terminal::Role::Dim, description)
);
}
match &trace.actual {
PredicateActualTrace::Context { value, .. } => {
println!(
"{pad} {}: {}",
terminal::stdout(terminal::Role::Fg, "context value"),
optional_json_value(value)?
);
}
PredicateActualTrace::Qualifier { trace, .. } => {
print_qualifier_resolution_trace(trace, indent + 1)?;
}
PredicateActualTrace::Bucket { value, bucket, .. } => {
println!(
"{pad} {}: {}",
terminal::stdout(terminal::Role::Fg, "context value"),
optional_json_value(value)?
);
let bucket = bucket
.map(|bucket| terminal::stdout(terminal::Role::Fg, bucket.to_string()))
.unwrap_or_else(|| terminal::stdout(terminal::Role::Dim, "<not computed>"));
println!(
"{pad} {}: {}",
terminal::stdout(terminal::Role::Fg, "bucket"),
bucket
);
}
}
Ok(())
}
fn predicate_expression(trace: &PredicateResolutionTrace) -> Result<String> {
match &trace.actual {
PredicateActualTrace::Context { path, .. } => Ok(format!(
"{} {} {}",
terminal::stdout(terminal::Role::Dim, path),
terminal::stdout(terminal::Role::Fg, &trace.op),
optional_json_value(&trace.expected)?
)),
PredicateActualTrace::Qualifier { id, .. } => Ok(format!(
"qualifier.{} {} {}",
terminal::stdout(terminal::Role::Clay, id),
terminal::stdout(terminal::Role::Fg, &trace.op),
optional_json_value(&trace.expected)?
)),
PredicateActualTrace::Bucket {
path,
salt,
start,
end,
..
} => Ok(format!(
"{} {} salt {} range [{},{})",
terminal::stdout(terminal::Role::Dim, path),
terminal::stdout(terminal::Role::Fg, "bucket"),
terminal::stdout(terminal::Role::Dim, salt),
start,
end
)),
}
}
fn optional_json_value(value: &Option<serde_json::Value>) -> Result<String> {
value
.as_ref()
.map(inline_json_value)
.unwrap_or_else(|| Ok(terminal::stdout(terminal::Role::Dim, "<missing>")))
}
fn inline_json_value(value: &serde_json::Value) -> Result<String> {
serde_json::to_string(value)
.map(|value| terminal::stdout(terminal::Role::Dim, value))
.map_err(|err| RototoError::new(err.to_string()))
}
fn bool_text(value: bool) -> String {
if value {
terminal::stdout(terminal::Role::Ok, "true")
} else {
terminal::stdout(terminal::Role::Dim, "false")
}
}
fn variable_selection_reason(reason: &VariableSelectionTrace) -> String {
match reason {
VariableSelectionTrace::Rule { index, qualifier } => format!(
"{}: {} {}",
terminal::stdout(terminal::Role::Fg, "first matching rule"),
terminal::stdout(terminal::Role::Fg, "rule"),
terminal::stdout(terminal::Role::Clay, format!("{index} {qualifier}"))
),
VariableSelectionTrace::Default => {
terminal::stdout(terminal::Role::Fg, "environment default")
}
}
}
fn print_trace_workspace(title: &str, trace: &WorkspaceTrace) {
println!("{}:", terminal::stdout_bold(terminal::Role::Fg, title));
println!(
" {}: {}",
terminal::stdout(terminal::Role::Fg, "source"),
terminal::stdout(terminal::Role::Dim, &trace.source)
);
println!(
" {}: {} ({})",
terminal::stdout(terminal::Role::Fg, "staged workspace"),
terminal::stdout(terminal::Role::Dim, &trace.staged_path),
terminal::stdout(terminal::Role::Dim, trace.staged_kind)
);
}
fn print_trace_targets(selectors: &EntityIdSelectors) {
println!(" {}:", terminal::stdout(terminal::Role::Fg, "targets"));
for id in &selectors.variables {
println!(
" {} {}",
terminal::stdout(terminal::Role::Fg, "variable"),
terminal::stdout(terminal::Role::Info, id)
);
}
for id in &selectors.qualifiers {
println!(
" {} {}",
terminal::stdout(terminal::Role::Fg, "qualifier"),
terminal::stdout(terminal::Role::Clay, id)
);
}
}
fn diagnostic_status(count: usize) -> String {
if count == 0 {
format!(
"{} ({})",
terminal::stdout(terminal::Role::Ok, "ok"),
terminal::stdout(terminal::Role::Dim, "0 diagnostics")
)
} else {
format!(
"{} ({})",
terminal::stdout(terminal::Role::Error, "failed"),
terminal::stdout(terminal::Role::Dim, format!("{count} diagnostics"))
)
}
}
fn print_docs(page: Option<&str>, search: &[String], json: bool) -> Result<ExitCode> {
if page.is_some() && !search.is_empty() {
return Err(RototoError::new(
"`rototo docs` accepts either --page or -s/--search, not both",
));
}
if let Some(page) = page {
return print_docs_page(page);
}
if !search.is_empty() {
return print_docs_search(search, json);
}
print_docs_list(json)?;
Ok(ExitCode::SUCCESS)
}
fn print_docs_list(json: bool) -> Result<()> {
if json {
println!(
"{}",
serde_json::to_string_pretty(rototo::docs::DOCS)
.map_err(|err| RototoError::new(err.to_string()))?
);
return Ok(());
}
let rows = docs_list_rows_for_pages(rototo::docs::DOCS.iter().collect());
print_docs_usage_hint();
print_docs_table(&rows, DocsTableStream::Stdout);
Ok(())
}
fn print_docs_usage_hint() {
println!("Use `rototo docs -p <page-prefix>` to print a page; prefixes work.");
println!("Use `rototo docs -s <regex> [regex ...]` to search page ids and content.");
println!("Example regex: `rototo docs -s 'refresh|last-known-good'`.");
println!();
}
fn print_docs_page(query: &str) -> Result<ExitCode> {
match rototo::docs::lookup_page(query) {
rototo::docs::DocPageLookup::Found(page) => {
print_docs_markdown(page.markdown);
Ok(ExitCode::SUCCESS)
}
rototo::docs::DocPageLookup::Ambiguous { query, pages } => {
eprintln!(
"{}: documentation page `{query}` matched multiple pages.",
terminal::stderr(terminal::Role::Error, "error")
);
print_docs_page_options("Which documentation page did you want?", pages);
Ok(ExitCode::FAILURE)
}
rototo::docs::DocPageLookup::NotFound { query, pages } => {
eprintln!(
"{}: no documentation page matched `{query}`.",
terminal::stderr(terminal::Role::Error, "error")
);
print_docs_page_options("Which documentation page did you want?", pages);
Ok(ExitCode::FAILURE)
}
}
}
fn print_docs_page_options(message: &str, pages: Vec<&'static rototo::docs::DocPage>) {
eprintln!("{message}");
eprintln!("Run `rototo docs --page <page>` with one of these page ids:");
let rows = docs_list_rows_for_pages(pages);
print_docs_table(&rows, DocsTableStream::Stderr);
}
fn print_docs_search(terms: &[String], json: bool) -> Result<ExitCode> {
let regexes = compile_docs_search_regexes(terms)?;
let matches = search_docs(®exes);
if json {
println!(
"{}",
serde_json::to_string_pretty(&matches)
.map_err(|err| RototoError::new(err.to_string()))?
);
return Ok(ExitCode::SUCCESS);
}
if matches.is_empty() {
println!("no documentation pages matched");
return Ok(ExitCode::SUCCESS);
}
print_docs_search_results(&matches, ®exes);
Ok(ExitCode::SUCCESS)
}
fn compile_docs_search_regexes(terms: &[String]) -> Result<Vec<Regex>> {
terms
.iter()
.map(|term| {
RegexBuilder::new(term)
.case_insensitive(true)
.build()
.map_err(|err| {
RototoError::new(format!("invalid docs search regex `{term}`: {err}"))
})
})
.collect::<Result<Vec<_>>>()
}
fn search_docs(regexes: &[Regex]) -> Vec<&'static rototo::docs::DocPage> {
rototo::docs::DOCS
.iter()
.filter(|page| {
let search_text = docs_search_text(page);
regexes.iter().all(|regex| regex.is_match(&search_text))
})
.collect()
}
fn docs_search_text(page: &rototo::docs::DocPage) -> String {
format!(
"{}\n{}\n{}\n{}",
page.id, page.title, page.list_title, page.markdown
)
}
fn print_docs_search_results(pages: &[&rototo::docs::DocPage], regexes: &[Regex]) {
for (index, page) in pages.iter().enumerate() {
if index > 0 {
println!();
}
print_docs_search_header(page, regexes);
for snippet in docs_search_snippets(page, regexes) {
print_search_snippet(&snippet, regexes);
}
}
}
fn print_docs_search_header(page: &rototo::docs::DocPage, regexes: &[Regex]) {
println!(
"{}: {}",
highlighted_search_text(page.id, regexes, terminal::Role::Info),
highlighted_search_text(page.list_title, regexes, terminal::Role::Fg)
);
}
fn docs_search_snippets(page: &rototo::docs::DocPage, regexes: &[Regex]) -> Vec<String> {
let candidates = docs_search_candidates(page);
let mut snippets = Vec::new();
for regex in regexes {
let Some(candidate) = candidates
.iter()
.find(|candidate| regex.is_match(candidate))
else {
continue;
};
let snippet = candidate.trim().to_owned();
if !snippet.is_empty() && !snippets.contains(&snippet) {
snippets.push(snippet);
}
}
snippets
}
fn docs_search_candidates(page: &rototo::docs::DocPage) -> Vec<String> {
let mut candidates = vec![
format!("page id: {}", page.id),
format!("title: {}", page.title),
format!("description: {}", page.list_title),
];
let mut in_code_block = false;
for line in page.markdown.lines() {
let line = line.trim();
if line.starts_with("```") {
in_code_block = !in_code_block;
continue;
}
if line.is_empty() {
continue;
}
let line = if in_code_block {
line.to_owned()
} else {
clean_markdown_search_line(line)
};
if !line.is_empty() {
candidates.push(line);
}
}
candidates
}
fn clean_markdown_search_line(line: &str) -> String {
let line = line.trim_start_matches('#').trim();
let line = line.strip_prefix("- ").unwrap_or(line).trim();
let line = strip_ordered_list_marker(line);
line.replace('`', "")
}
fn strip_ordered_list_marker(line: &str) -> &str {
let Some((marker, rest)) = line.split_once(' ') else {
return line;
};
if marker.ends_with('.')
&& marker[..marker.len() - 1]
.chars()
.all(|character| character.is_ascii_digit())
{
rest.trim()
} else {
line
}
}
fn print_search_snippet(snippet: &str, regexes: &[Regex]) {
let prefix = " ";
let width = docs_terminal_width()
.saturating_sub(prefix.chars().count())
.max(1);
for line in wrap_words(snippet, width) {
println!(
"{}{}",
prefix,
highlighted_search_text(&line, regexes, terminal::Role::Dim)
);
}
}
fn highlighted_search_text(text: &str, regexes: &[Regex], base_role: terminal::Role) -> String {
let ranges = search_match_ranges(text, regexes);
if ranges.is_empty() {
return terminal::stdout(base_role, text);
}
let mut output = String::new();
let mut cursor = 0;
for (start, end) in ranges {
if cursor < start {
output.push_str(&terminal::stdout(base_role, &text[cursor..start]));
}
output.push_str(&terminal::stdout_bold(
terminal::Role::Ok,
&text[start..end],
));
cursor = end;
}
if cursor < text.len() {
output.push_str(&terminal::stdout(base_role, &text[cursor..]));
}
output
}
fn search_match_ranges(text: &str, regexes: &[Regex]) -> Vec<(usize, usize)> {
let mut ranges = Vec::new();
for regex in regexes {
ranges.extend(
regex
.find_iter(text)
.filter(|matched| !matched.is_empty())
.map(|matched| (matched.start(), matched.end())),
);
}
ranges.sort_unstable();
let mut merged: Vec<(usize, usize)> = Vec::new();
for (start, end) in ranges {
let Some((_, previous_end)) = merged.last_mut() else {
merged.push((start, end));
continue;
};
if start <= *previous_end {
*previous_end = (*previous_end).max(end);
} else {
merged.push((start, end));
}
}
merged
}
fn print_docs_markdown(markdown: &str) {
let mut events = MarkdownParser::new_ext(markdown, markdown_options()).peekable();
let mut printer = MarkdownPrinter::new(docs_terminal_width());
while let Some(event) = events.next() {
match event {
Event::Start(Tag::Heading { level, .. }) => {
let text = collect_markdown_text(&mut events, MarkdownEndTag::Heading(level));
printer.heading(level, &text);
}
Event::Start(Tag::Paragraph) => {
let text = collect_markdown_text(&mut events, MarkdownEndTag::Paragraph);
printer.paragraph(&text);
}
Event::Start(Tag::CodeBlock(kind)) => {
let code = collect_markdown_code_block(&mut events);
printer.code_block(&kind, &code);
}
Event::Start(Tag::List(start)) => {
print_markdown_list(&mut events, &mut printer, start);
}
Event::Rule => printer.rule(),
Event::Text(text) => printer.paragraph(&text),
_ => {}
}
}
}
fn markdown_options() -> Options {
let mut options = Options::empty();
options.insert(Options::ENABLE_TABLES);
options.insert(Options::ENABLE_FOOTNOTES);
options.insert(Options::ENABLE_STRIKETHROUGH);
options
}
fn print_markdown_list<'a, I>(
events: &mut std::iter::Peekable<I>,
printer: &mut MarkdownPrinter,
start: Option<u64>,
) where
I: Iterator<Item = Event<'a>>,
{
let mut index = start.unwrap_or(1);
let ordered = start.is_some();
let mut items = Vec::new();
while let Some(event) = events.next() {
match event {
Event::Start(Tag::Item) => {
let item = collect_markdown_text(events, MarkdownEndTag::Item);
let marker = if ordered {
let marker = format!("{index}.");
index += 1;
marker
} else {
"-".to_owned()
};
items.push((marker, item));
}
Event::End(TagEnd::List(_)) => break,
_ => {}
}
}
printer.list(&items);
}
#[derive(Clone, Copy)]
enum MarkdownEndTag {
Heading(HeadingLevel),
Paragraph,
Item,
Link,
Image,
}
impl MarkdownEndTag {
fn matches(self, end: &TagEnd) -> bool {
match (self, end) {
(Self::Heading(level), TagEnd::Heading(end_level)) => level == *end_level,
(Self::Paragraph, TagEnd::Paragraph)
| (Self::Item, TagEnd::Item)
| (Self::Link, TagEnd::Link)
| (Self::Image, TagEnd::Image) => true,
_ => false,
}
}
}
fn collect_markdown_text<'a, I>(
events: &mut std::iter::Peekable<I>,
end_tag: MarkdownEndTag,
) -> String
where
I: Iterator<Item = Event<'a>>,
{
let mut text = String::new();
while let Some(event) = events.next() {
match event {
Event::End(end) if end_tag.matches(&end) => break,
Event::Text(value) => push_markdown_text(&mut text, &value),
Event::Code(value) => push_markdown_text(&mut text, &format!("`{value}`")),
Event::SoftBreak => push_markdown_text(&mut text, " "),
Event::HardBreak => text.push('\n'),
Event::Start(Tag::Link { dest_url, .. }) => {
let link_text = collect_markdown_text(events, MarkdownEndTag::Link);
push_markdown_text(&mut text, &link_text);
if !dest_url.is_empty() {
push_markdown_text(&mut text, &format!("({})", cli_doc_link(&dest_url)));
}
}
Event::Start(Tag::Image { dest_url, .. }) => {
let alt = collect_markdown_text(events, MarkdownEndTag::Image);
if !alt.is_empty() {
push_markdown_text(&mut text, &alt);
}
if !dest_url.is_empty() {
push_markdown_text(&mut text, &format!("({dest_url})"));
}
}
Event::Start(Tag::CodeBlock(_)) => {
let code = collect_markdown_code_block(events);
push_markdown_text(&mut text, code.trim());
}
_ => {}
}
}
text.trim().to_owned()
}
fn push_markdown_text(target: &mut String, value: &str) {
if value.is_empty() {
return;
}
if !target.is_empty()
&& !target.ends_with([' ', '\n', '(', '['])
&& !value.starts_with([' ', '\n', '.', ',', ':', ';', '?', '!', ')', ']'])
{
target.push(' ');
}
target.push_str(value);
}
fn cli_doc_link(dest_url: &str) -> String {
let page = dest_url.split('#').next().unwrap_or(dest_url);
let Some(id) = page.strip_suffix(".html") else {
return dest_url.to_owned();
};
if rototo::docs::DOCS.iter().any(|page| page.id == id) {
format!("rototo docs -p {id}")
} else {
dest_url.to_owned()
}
}
fn collect_markdown_code_block<'a, I>(events: &mut std::iter::Peekable<I>) -> String
where
I: Iterator<Item = Event<'a>>,
{
let mut code = String::new();
for event in events.by_ref() {
match event {
Event::End(TagEnd::CodeBlock) => break,
Event::Text(value)
| Event::Code(value)
| Event::Html(value)
| Event::InlineHtml(value) => {
code.push_str(&value);
}
Event::SoftBreak | Event::HardBreak => code.push('\n'),
_ => {}
}
}
code
}
struct MarkdownPrinter {
width: usize,
first_block: bool,
}
impl MarkdownPrinter {
fn new(width: usize) -> Self {
Self {
width: width.max(24),
first_block: true,
}
}
fn heading(&mut self, level: HeadingLevel, text: &str) {
let text = text.trim();
if text.is_empty() {
return;
}
self.blank_before();
let lines = wrap_words(text, self.width);
for line in &lines {
println!("{}", terminal::stdout_bold(terminal::Role::Fg, line));
}
let underline = match level {
HeadingLevel::H1 => Some('='),
HeadingLevel::H2 => Some('-'),
_ => None,
};
if let Some(character) = underline {
let width = lines
.last()
.map(|line| line.chars().count().min(self.width))
.unwrap_or_default();
println!(
"{}",
terminal::stdout(terminal::Role::Dim, character.to_string().repeat(width))
);
}
}
fn paragraph(&mut self, text: &str) {
let text = text.trim();
if text.is_empty() {
return;
}
self.blank_before();
for line in wrap_words(text, self.width) {
println!("{line}");
}
}
fn list(&mut self, items: &[(String, String)]) {
if items.is_empty() {
return;
}
self.blank_before();
for (marker, item) in items {
let prefix = format!(" {marker} ");
let continuation = " ".repeat(prefix.chars().count());
self.wrapped_with_prefix(&prefix, &continuation, item);
}
}
fn code_block(&mut self, kind: &CodeBlockKind<'_>, code: &str) {
self.blank_before();
if let Some(language) = code_block_language(kind) {
println!(
"{}",
terminal::stdout(terminal::Role::Dim, format!(" [{language}]"))
);
}
for line in code.trim_end_matches('\n').lines() {
println!(
"{}",
terminal::stdout(terminal::Role::Dim, format!(" {line}"))
);
}
}
fn rule(&mut self) {
self.blank_before();
println!(
"{}",
terminal::stdout(terminal::Role::Dim, "-".repeat(self.width.min(72)))
);
}
fn wrapped_with_prefix(&self, prefix: &str, continuation: &str, text: &str) {
let text_width = self.width.saturating_sub(prefix.chars().count()).max(1);
let mut lines = wrap_words(text.trim(), text_width);
let first_line = lines.remove(0);
println!("{prefix}{first_line}");
for line in lines {
println!("{continuation}{line}");
}
}
fn blank_before(&mut self) {
if self.first_block {
self.first_block = false;
} else {
println!();
}
}
}
fn code_block_language(kind: &CodeBlockKind<'_>) -> Option<String> {
match kind {
CodeBlockKind::Fenced(language) if !language.is_empty() => Some(language.to_string()),
_ => None,
}
}
struct DocsListRow {
page: String,
title: &'static str,
kind: DocsListRowKind,
}
enum DocsListRowKind {
Section,
Page,
}
#[derive(Clone, Copy)]
enum DocsTableStream {
Stdout,
Stderr,
}
fn docs_list_rows_for_pages(pages: Vec<&'static rototo::docs::DocPage>) -> Vec<DocsListRow> {
let mut rows = Vec::new();
for section in rototo::docs::DOC_NAV_SECTIONS {
let mut section_rows = Vec::new();
for page_id in section.pages {
let Some(page) = pages.iter().find(|page| page.id == *page_id) else {
continue;
};
section_rows.push(DocsListRow {
page: format!(" {}", page.id),
title: page.list_title,
kind: DocsListRowKind::Page,
});
}
if !section_rows.is_empty() {
rows.push(DocsListRow {
page: section.title.to_owned(),
title: "",
kind: DocsListRowKind::Section,
});
rows.extend(section_rows);
}
}
rows
}
fn print_docs_table(rows: &[DocsListRow], stream: DocsTableStream) {
let title_width = docs_list_title_width();
let page_header = docs_page_cell("Page");
print_docs_table_line(
stream,
format!(
"{}{DOCS_LIST_SEPARATOR}{}",
docs_bold(stream, terminal::Role::Info, page_header),
docs_bold(stream, terminal::Role::Fg, "Title")
),
);
print_docs_table_line(
stream,
format!(
"{}-+-{}",
"-".repeat(DOCS_LIST_PAGE_WIDTH),
"-".repeat(title_width)
),
);
for row in rows {
match row.kind {
DocsListRowKind::Section => {
print_docs_table_line(
stream,
format!(
"{} |",
docs_style(stream, terminal::Role::Clay, docs_page_cell(&row.page))
),
);
}
DocsListRowKind::Page => {
let mut wrapped = wrap_words(row.title, title_width);
let first_line = wrapped.remove(0);
print_docs_table_line(
stream,
format!(
"{}{DOCS_LIST_SEPARATOR}{}",
docs_style(stream, terminal::Role::Info, docs_page_cell(&row.page)),
docs_style(stream, terminal::Role::Fg, first_line)
),
);
for line in wrapped {
print_docs_table_line(
stream,
format!(
"{}{DOCS_LIST_SEPARATOR}{}",
" ".repeat(DOCS_LIST_PAGE_WIDTH),
docs_style(stream, terminal::Role::Fg, line)
),
);
}
}
}
}
}
fn docs_list_title_width() -> usize {
docs_terminal_width()
.saturating_sub(DOCS_LIST_PAGE_WIDTH + DOCS_LIST_SEPARATOR.len())
.max(1)
}
fn docs_terminal_width() -> usize {
std::env::var("COLUMNS")
.ok()
.and_then(|value| value.parse::<usize>().ok())
.filter(|width| *width > 0)
.unwrap_or(DOCS_LIST_FALLBACK_WIDTH)
}
fn docs_page_cell(page: &str) -> String {
format!(
"{:<DOCS_LIST_PAGE_WIDTH$}",
truncate_for_display(page, DOCS_LIST_PAGE_WIDTH)
)
}
fn truncate_for_display(text: &str, width: usize) -> String {
if text.chars().count() <= width {
return text.to_owned();
}
if width <= 3 {
return text.chars().take(width).collect();
}
let mut truncated = text.chars().take(width - 3).collect::<String>();
while truncated.ends_with('-') {
truncated.pop();
}
truncated.push_str("...");
truncated
}
fn wrap_words(text: &str, width: usize) -> Vec<String> {
if text.is_empty() {
return vec![String::new()];
}
let mut lines = Vec::new();
let mut current = String::new();
for word in text.split_whitespace() {
if word.chars().count() > width {
if !current.is_empty() {
lines.push(std::mem::take(&mut current));
}
lines.extend(split_long_word(word, width));
} else if current.is_empty() {
current.push_str(word);
} else if current.chars().count() + 1 + word.chars().count() <= width {
current.push(' ');
current.push_str(word);
} else {
lines.push(std::mem::take(&mut current));
current.push_str(word);
}
}
if !current.is_empty() {
lines.push(current);
}
if lines.is_empty() {
lines.push(String::new());
}
lines
}
fn split_long_word(word: &str, width: usize) -> Vec<String> {
let width = width.max(1);
let mut lines = Vec::new();
let mut current = String::new();
for character in word.chars() {
if current.chars().count() == width {
lines.push(std::mem::take(&mut current));
}
current.push(character);
}
if !current.is_empty() {
lines.push(current);
}
lines
}
fn docs_bold(stream: DocsTableStream, role: terminal::Role, text: impl AsRef<str>) -> String {
match stream {
DocsTableStream::Stdout => terminal::stdout_bold(role, text),
DocsTableStream::Stderr => terminal::stderr_bold(role, text),
}
}
fn docs_style(stream: DocsTableStream, role: terminal::Role, text: impl AsRef<str>) -> String {
match stream {
DocsTableStream::Stdout => terminal::stdout(role, text),
DocsTableStream::Stderr => terminal::stderr(role, text),
}
}
fn print_docs_table_line(stream: DocsTableStream, line: String) {
match stream {
DocsTableStream::Stdout => println!("{line}"),
DocsTableStream::Stderr => eprintln!("{line}"),
}
}
async fn parse_context(parts: &[String]) -> Result<serde_json::Value> {
let mut context = serde_json::Value::Object(serde_json::Map::new());
for part in parts {
let value = parse_context_part(part).await?;
merge_context(&mut context, value)?;
}
Ok(context)
}
async fn parse_context_part(part: &str) -> Result<serde_json::Value> {
if let Some(path) = part.strip_prefix('@') {
if path.is_empty() {
return Err(RototoError::new("context file path must not be empty"));
}
let text = tokio::fs::read_to_string(path).await.map_err(|err| {
RototoError::new(format!("failed to read context file {path}: {err}"))
})?;
return parse_context_json(&text);
}
if part.trim_start().starts_with('{') {
return parse_context_json(part);
}
if let Some((path, value)) = part.split_once('=') {
if path.is_empty() {
return Err(RototoError::new(
"context assignment path must not be empty",
));
}
return context_assignment(path, value);
}
parse_context_json(part)
}
fn parse_context_json(text: &str) -> Result<serde_json::Value> {
let context: serde_json::Value = serde_json::from_str(text)
.map_err(|err| RototoError::new(format!("failed to parse context JSON: {err}")))?;
if !context.is_object() {
return Err(RototoError::new("context JSON must be an object"));
}
Ok(context)
}
fn context_assignment(path: &str, value: &str) -> Result<serde_json::Value> {
let value =
serde_json::from_str(value).unwrap_or_else(|_| serde_json::Value::String(value.to_owned()));
let mut root = serde_json::Map::new();
insert_context_path(&mut root, path, value)?;
Ok(serde_json::Value::Object(root))
}
fn insert_context_path(
object: &mut serde_json::Map<String, serde_json::Value>,
path: &str,
value: serde_json::Value,
) -> Result<()> {
let mut segments = path.split('.').peekable();
let mut current = object;
while let Some(segment) = segments.next() {
if segment.is_empty() {
return Err(RototoError::new(format!("invalid context path: {path}")));
}
if segments.peek().is_none() {
current.insert(segment.to_owned(), value);
return Ok(());
}
let entry = current
.entry(segment.to_owned())
.or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()));
if !entry.is_object() {
*entry = serde_json::Value::Object(serde_json::Map::new());
}
current = entry.as_object_mut().expect("object inserted above");
}
Err(RototoError::new(
"context assignment path must not be empty",
))
}
fn merge_context(target: &mut serde_json::Value, source: serde_json::Value) -> Result<()> {
let (Some(target), Some(source)) = (target.as_object_mut(), source.as_object()) else {
return Err(RototoError::new("context must be a JSON object"));
};
merge_context_objects(target, source);
Ok(())
}
fn merge_context_objects(
target: &mut serde_json::Map<String, serde_json::Value>,
source: &serde_json::Map<String, serde_json::Value>,
) {
for (key, value) in source {
match (target.get_mut(key), value) {
(Some(existing), serde_json::Value::Object(source_object)) if existing.is_object() => {
merge_context_objects(
existing.as_object_mut().expect("checked above"),
source_object,
);
}
_ => {
target.insert(key.clone(), value.clone());
}
}
}
}
fn source_options(cli: &Cli) -> SourceOptions {
match &cli.workspace_token {
Some(token) => SourceOptions::new().with_auth(SourceAuth::Bearer(token.clone())),
None => SourceOptions::new(),
}
}
fn init_tracing() {
let filter = tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("warn"));
tracing_subscriber::fmt()
.with_env_filter(filter)
.with_target(false)
.init();
}