mod data_json;
mod error_formatter;
mod formatter;
mod interactive;
mod mcp;
pub(crate) mod server;
use anyhow::Result;
use clap::{CommandFactory, Parser, Subcommand};
use formatter::{Formatter, RepositorySpecGroup};
use lemma::DateTimeValue;
use lemma::Engine;
use lemma_cli::deps::{
dependency_identifier_from_dependency_path, lemma_deps_dir, relative_dependency_cache_path,
};
use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use walkdir::WalkDir;
#[derive(Parser)]
#[command(name = "lemma")]
#[command(about = "A pure, declarative language for business rules.")]
#[command(
long_about = "Lemma is a declarative programming language for business logic, expressed simply and clearly.\nThe CLI lets you evaluate rules from .lemma files, run Lemma as an HTTP server, or integrate with AI tools via MCP."
)]
#[command(version)]
struct Cli {
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand)]
enum Commands {
Run {
args: Vec<String>,
#[arg(long, value_name = "PATH")]
prefix: Option<PathBuf>,
#[arg(long, value_name = "RULES")]
rules: Option<String>,
#[arg(short = 'x', long)]
explain: bool,
#[arg(long)]
json: bool,
#[arg(short = 'i', long)]
interactive: bool,
#[arg(long)]
effective: Option<String>,
},
Show {
repo: Option<String>,
spec: Option<String>,
#[arg(long, value_name = "PATH")]
prefix: Option<PathBuf>,
#[arg(long)]
effective: Option<String>,
#[arg(long)]
json: bool,
},
List {
#[arg(long, value_name = "PATH")]
prefix: Option<PathBuf>,
#[arg(long)]
json: bool,
},
Server {
#[arg(long, value_name = "PATH")]
prefix: Option<PathBuf>,
#[arg(long, default_value = "127.0.0.1")]
host: String,
#[arg(short, long, default_value = "8012")]
port: u16,
#[arg(short, long)]
watch: bool,
#[arg(long)]
explanations: bool,
#[arg(long, default_value = "10", value_name = "SECONDS")]
eval_timeout: u64,
#[arg(long)]
cors: bool,
},
Lsp,
Mcp {
#[arg(long, value_name = "PATH")]
prefix: Option<PathBuf>,
#[arg(long)]
admin: bool,
#[arg(long, default_value = "10", value_name = "SECONDS")]
request_timeout: u64,
},
Fetch {
dependency: Option<String>,
#[arg(long, value_name = "PATH")]
prefix: Option<PathBuf>,
#[arg(short = 'a', long)]
all: bool,
#[arg(short = 'f', long)]
force: bool,
},
Format {
#[arg(default_value = ".")]
paths: Vec<PathBuf>,
#[arg(long)]
check: bool,
#[arg(long)]
stdout: bool,
},
}
struct RunArgs {
positionals: Vec<String>,
data: Vec<String>,
}
fn parse_run_args(arguments: &[String]) -> Result<RunArgs> {
let mut data = Vec::new();
let mut positionals = Vec::new();
for argument in arguments {
if argument.contains('=') {
data.push(argument.clone());
} else {
if argument == "-" {
anyhow::bail!(
"`-` is not a valid path (stdin is not supported); use `--prefix` for workspace files"
);
}
positionals.push(argument.to_string());
}
}
if positionals.len() > 2 {
anyhow::bail!(
"Too many positional arguments; expected [repo] [spec], [spec], or [repo] with --interactive (use `--prefix` for workspace path)"
);
}
for pos in &positionals {
if Path::new(pos).exists() {
anyhow::bail!(
"Workspace path must be passed with --prefix {}, not as a bare positional",
pos
);
}
}
Ok(RunArgs { positionals, data })
}
fn workspace_dir(prefix: Option<&PathBuf>) -> &Path {
prefix
.map(|p| p.as_path())
.unwrap_or_else(|| Path::new("."))
}
fn resolve_spec(engine: &Engine, spec: Option<&str>, interactive: bool) -> Result<String> {
if let Some(name) = spec {
return Ok(name.to_string());
}
let workspace = engine
.list()
.into_iter()
.find(|repository_group| repository_group.repository.is_none())
.expect("BUG: workspace repository must exist after Engine::new")
.specs;
let unique_names: std::collections::BTreeSet<&str> =
workspace.iter().map(|ls| ls.name.as_str()).collect();
match unique_names.len() {
0 => anyhow::bail!("No specs found in source"),
1 => Ok(unique_names
.into_iter()
.next()
.expect("BUG: len was 1")
.to_string()),
_ if interactive => Ok(String::new()),
_ => {
let names: Vec<&str> = unique_names.into_iter().collect();
anyhow::bail!(
"Workspace contains multiple specs: {}\n\nUsage: lemma run [repo] <spec> [--prefix PATH] [name=value ...]",
names.join(", ")
);
}
}
}
fn resolve_effective(cli_effective: Option<&String>) -> Result<DateTimeValue> {
lemma::resolve_effective(cli_effective.map(String::as_str))
.map_err(|e| anyhow::anyhow!("{}", e.message()))
}
fn main() {
let cli = Cli::parse();
let result: Result<()> = (|| match &cli.command {
Commands::Run {
args,
prefix,
rules,
explain,
interactive,
effective,
json,
} => {
let parsed_run = parse_run_args(args)?;
let workdir = prefix.as_deref().unwrap_or_else(|| Path::new("."));
run_command(RunOptions {
source: workdir,
positionals: &parsed_run.positionals,
rules: rules.as_ref(),
data: &parsed_run.data,
explain: *explain,
interactive: *interactive,
effective: effective.as_ref(),
json: *json,
})
}
Commands::Show {
repo,
spec,
prefix,
effective,
json,
} => show_command(
workspace_dir(prefix.as_ref()),
repo.as_deref(),
spec.as_deref(),
effective.as_ref(),
*json,
),
Commands::List { prefix, json } => list_command(workspace_dir(prefix.as_ref()), *json),
Commands::Server {
prefix,
host,
port,
watch,
explanations,
eval_timeout,
cors,
} => server_command(
workspace_dir(prefix.as_ref()),
host,
*port,
*watch,
*explanations,
*eval_timeout,
*cors,
),
Commands::Lsp => lsp_command(),
Commands::Mcp {
prefix,
admin,
request_timeout,
} => mcp_command(workspace_dir(prefix.as_ref()), *admin, *request_timeout),
Commands::Fetch {
dependency,
prefix,
all,
force,
} => {
if dependency.is_some() && *all {
anyhow::bail!("Cannot specify both a dependency and --all");
}
if dependency.is_none() && !*all {
let mut cmd = Cli::command();
cmd.build();
let fetch_cmd = cmd
.find_subcommand_mut("fetch")
.expect("BUG: Cli must define fetch subcommand");
let _ = fetch_cmd.print_help();
std::process::exit(1);
}
fetch_command(
workspace_dir(prefix.as_ref()),
dependency.as_deref(),
*force,
)
}
Commands::Format {
paths,
check,
stdout,
} => format_command(paths, *check, *stdout),
})();
if let Err(e) = result {
eprintln!("{}", e);
std::process::exit(1);
}
}
struct RunOptions<'a> {
source: &'a Path,
positionals: &'a [String],
rules: Option<&'a String>,
data: &'a [String],
explain: bool,
interactive: bool,
effective: Option<&'a String>,
json: bool,
}
fn run_command(options: RunOptions<'_>) -> Result<()> {
let now = resolve_effective(options.effective)?;
let mut engine = Engine::new();
let _: usize = load_workspace(&mut engine, options.source)?;
let (repository_qualifier_optional, spec_name_optional) = match options.positionals {
[] => (None, None),
[one] => {
let is_repo = interactive::repository_loaded(&engine, one);
let is_spec = engine
.list()
.into_iter()
.find(|repository_group| repository_group.repository.is_none())
.expect("BUG: workspace repository must exist after Engine::new")
.specs
.iter()
.any(|ls| ls.name == *one);
if is_repo && !is_spec {
(Some(one.to_string()), None)
} else if is_spec && !is_repo {
(None, Some(one.to_string()))
} else if is_repo && is_spec {
if options.interactive {
anyhow::bail!(
"'{}' resolves to both a repository and a specification. Please specify both [repo] [spec] to disambiguate.",
one
);
} else {
(None, Some(one.to_string()))
}
} else {
(None, Some(one.to_string()))
}
}
[repo, spec] => (Some(repo.to_string()), Some(spec.to_string())),
_ => unreachable!("Parser ensures <= 2 positionals"),
};
if repository_qualifier_optional.is_some()
&& spec_name_optional.is_none()
&& !options.interactive
{
anyhow::bail!(
"Repository positional requires a specification name (second argument), or use --interactive"
);
}
let resolved_spec_name =
resolve_spec(&engine, spec_name_optional.as_deref(), options.interactive)?;
let (repository_qualifier_for_run, spec_set_identifier, rule_names, evaluation_inputs) =
if options.interactive {
let (interactive_spec_preset, interactive_rules_preset) =
if resolved_spec_name.is_empty() {
(None, None)
} else {
let preset_identifier = lemma::parse_spec_set_id(&resolved_spec_name)
.map_err(|e| anyhow::anyhow!("{}", e))?;
(
Some(preset_identifier),
options
.rules
.map(|rules_fragment| parse_rule_names(rules_fragment.as_str())),
)
};
let command_line_data: HashMap<String, String> = parse_data_strings(options.data);
let interactive_outcome = interactive::run_interactive(
&engine,
interactive_spec_preset,
interactive_rules_preset,
&command_line_data,
&now,
repository_qualifier_optional.as_deref(),
)?;
let (
chosen_repository_qualifier,
chosen_specification_name,
interactive_rules_selection,
prompted_data,
) = interactive_outcome;
println!();
let mut merged_inputs = command_line_data;
merged_inputs.extend(prompted_data);
let interactive_spec_id = lemma::parse_spec_set_id(&chosen_specification_name)
.map_err(|e| anyhow::anyhow!("{}", e))?;
(
chosen_repository_qualifier,
interactive_spec_id,
interactive_rules_selection.unwrap_or_default(),
merged_inputs,
)
} else {
let non_interactive_spec_id = lemma::parse_spec_set_id(&resolved_spec_name)
.map_err(|e| anyhow::anyhow!("{}", e))?;
let rule_names: Vec<String> = options
.rules
.map(|rules_fragment| parse_rule_names(rules_fragment.as_str()))
.unwrap_or_default();
let evaluation_inputs = parse_data_strings(options.data);
(
repository_qualifier_optional,
non_interactive_spec_id,
rule_names,
evaluation_inputs,
)
};
let rules = if rule_names.is_empty() {
None
} else {
Some(rule_names.as_slice())
};
let response = engine
.run(
repository_qualifier_for_run.as_deref(),
&spec_set_identifier,
Some(&now),
evaluation_inputs,
rules,
options.explain,
)
.map_err(|e| anyhow::anyhow!("{e}"))?;
let formatter = Formatter;
if options.json {
let json_document = if options.explain {
serde_json::to_string_pretty(&response).expect("BUG: failed to serialize response JSON")
} else {
formatter.serialize_response_json(&response, false)
};
println!("{}", json_document);
} else {
print!("{}", formatter.format_response(&response, options.explain));
}
Ok(())
}
fn parse_data_strings(data: &[String]) -> HashMap<String, String> {
data.iter()
.filter_map(|s| {
s.split_once('=')
.map(|(k, v)| (k.to_string(), v.to_string()))
})
.collect()
}
fn resolve_show_target(
engine: &Engine,
repository_qualifier: Option<&str>,
specification_name: Option<&str>,
) -> Result<(Option<String>, String)> {
match (repository_qualifier, specification_name) {
(None, Some(spec)) => Ok((None, spec.to_string())),
(Some(repo), Some(spec)) => Ok((Some(repo.to_string()), spec.to_string())),
(Some(one), None) => {
let is_repository = interactive::repository_loaded(engine, one);
let is_spec = engine
.list()
.into_iter()
.find(|repository_group| repository_group.repository.is_none())
.expect("BUG: workspace repository must exist after Engine::new")
.specs
.iter()
.any(|ls| ls.name == *one);
if is_repository && !is_spec {
anyhow::bail!(
"Repository positional requires a specification name (second argument)"
);
}
Ok((None, one.to_string()))
}
(None, None) => {
let chosen = resolve_spec(engine, None, false)?;
Ok((None, chosen))
}
}
}
fn show_command(
source_path: &Path,
repository_qualifier: Option<&str>,
specification_name: Option<&str>,
effective: Option<&String>,
json: bool,
) -> Result<()> {
let now = resolve_effective(effective)?;
let mut engine = Engine::new();
let _: usize = load_workspace(&mut engine, source_path)?;
let (repository_for_show, chosen_specification) =
resolve_show_target(&engine, repository_qualifier, specification_name)?;
let show = engine
.show(
repository_for_show.as_deref(),
&chosen_specification,
Some(&now),
)
.map_err(|e| anyhow::anyhow!("{e}"))?;
if json {
let json_document =
serde_json::to_string_pretty(&show).expect("BUG: failed to serialize show JSON");
println!("{}", json_document);
} else {
print!("{show}");
}
Ok(())
}
fn spec_set_names_in_repository(repo: &lemma::ResolvedRepository) -> Vec<String> {
let unique_names: std::collections::BTreeSet<String> =
repo.specs.iter().map(|ls| ls.name.clone()).collect();
unique_names.into_iter().collect()
}
fn repository_spec_groups(engine: &Engine) -> Vec<(Option<String>, Vec<String>)> {
let mut groups: Vec<(Option<String>, Vec<String>)> = Vec::new();
for resolved in engine.list() {
let names = spec_set_names_in_repository(&resolved);
if names.is_empty() {
continue;
}
match resolved.repository.as_deref() {
None => groups.push((None, names)),
Some(repository) => groups.push((Some(repository.to_string()), names)),
}
}
groups
}
fn list_command(source_path: &Path, json: bool) -> Result<()> {
let mut engine = Engine::new();
let _: usize = load_workspace(&mut engine, source_path)?;
if json {
let payload = engine.list();
let json_document =
serde_json::to_string_pretty(&payload).expect("BUG: failed to serialize list JSON");
print!("{}", json_document);
return Ok(());
}
let groups = repository_spec_groups(&engine);
let formatter = Formatter;
let view_groups: Vec<RepositorySpecGroup<'_>> = groups
.iter()
.map(|(repository, specs)| RepositorySpecGroup {
repository: repository.as_deref(),
specs: specs.as_slice(),
})
.collect();
print!("{}", formatter.format_repository_spec_list(&view_groups));
Ok(())
}
#[allow(clippy::too_many_arguments)]
fn server_command(
source: &Path,
host: &str,
port: u16,
watch: bool,
explanations: bool,
eval_timeout_secs: u64,
cors: bool,
) -> Result<()> {
use tokio::runtime::Runtime;
let rt = Runtime::new()?;
rt.block_on(async {
let mut engine = Engine::new();
let _: usize = load_workspace(&mut engine, source)?;
let workspace = engine
.list()
.into_iter()
.find(|repository_group| repository_group.repository.is_none())
.expect("BUG: workspace repository must exist after Engine::new")
.specs;
let unique_specs: std::collections::BTreeSet<&str> =
workspace.iter().map(|ls| ls.name.as_str()).collect();
println!(
"Starting HTTP server with {} spec(s) loaded...",
unique_specs.len()
);
server::http::start_server(
engine,
host,
port,
watch,
explanations,
source.to_path_buf(),
eval_timeout_secs,
cors,
)
.await
})?;
Ok(())
}
fn lsp_command() -> Result<()> {
lemma_lsp::stdio::run_stdio().map_err(anyhow::Error::from)
}
fn mcp_command(workdir: &Path, admin: bool, request_timeout_secs: u64) -> Result<()> {
let mut engine = Engine::new();
let _: usize = load_workspace(&mut engine, workdir)?;
let config = mcp::McpConfig {
admin,
request_timeout: std::time::Duration::from_secs(request_timeout_secs),
};
let workspace_specs = engine
.list()
.into_iter()
.find(|repository_group| repository_group.repository.is_none())
.expect("BUG: workspace repository must exist after Engine::new")
.specs;
let unique_specs: std::collections::BTreeSet<&str> =
workspace_specs.iter().map(|ls| ls.name.as_str()).collect();
eprintln!(
"Starting MCP server with {} spec(s) loaded",
unique_specs.len()
);
mcp::server::start_server(engine, config)?;
Ok(())
}
fn fetch_command(source: &Path, spec_name: Option<&str>, force: bool) -> Result<()> {
let rt = tokio::runtime::Runtime::new()?;
rt.block_on(fetch_command_async(source, spec_name, force))
}
async fn fetch_command_async(source: &Path, spec_name: Option<&str>, force: bool) -> Result<()> {
let registry = make_fetch_registry();
match spec_name {
Some(id) => fetch_repo(source, id, registry.as_ref(), force).await,
None => fetch_workspace_deps(source, registry.as_ref(), force).await,
}
}
async fn fetch_repo(
workdir: &Path,
raw_id: &str,
registry: &dyn lemma::Registry,
force: bool,
) -> Result<()> {
if raw_id.is_empty() {
anyhow::bail!("Empty repo identifier. Usage: lemma fetch @user/repo");
}
let bundle = registry
.get(raw_id)
.await
.map_err(|e| anyhow::anyhow!("Registry error for {}: {}", raw_id, e.message))?;
let attribute = raw_id;
let source_text = &bundle.source;
let deps_dir = lemma_deps_dir(workdir);
let limits = lemma::ResourceLimits::default();
let new_specs = lemma::parse(
source_text,
lemma::SourceType::Dependency(raw_id.to_string()),
&limits,
)
.map_err(|e| anyhow::anyhow!("Registry returned unparseable dependency: {}", e.message()))?
.into_flattened_specs();
let new_spec_names: std::collections::HashSet<String> =
new_specs.iter().map(|s| s.name.clone()).collect();
if deps_dir.exists() {
for entry in WalkDir::new(&deps_dir) {
let entry = entry?;
if entry.path().extension().and_then(|s| s.to_str()) != Some("lemma") {
continue;
}
let path = entry.path();
let existing_content = fs::read_to_string(path)?;
if existing_content == *source_text {
eprintln!("Already up to date: {}.", raw_id);
return Ok(());
}
let existing_specs = match lemma::parse(
&existing_content,
lemma::SourceType::Path(Arc::new(path.to_path_buf())),
&limits,
) {
Ok(r) => r.into_flattened_specs(),
Err(_) => continue,
};
let conflict: Vec<&str> = existing_specs
.iter()
.filter(|s| new_spec_names.contains(&s.name))
.map(|s| s.name.as_str())
.collect();
if !conflict.is_empty() {
if !force {
anyhow::bail!(
"Dependency containing spec(s) {} already exists in {}.\n\
Content has changed on the registry. Re-run with --force to overwrite.",
conflict.join(", "),
path.display()
);
}
fs::remove_file(path)?;
eprintln!(" removed: {}", path.display());
}
}
}
lemma::parse_spec_set_id(raw_id).map_err(|e| anyhow::anyhow!("{}", e))?;
let mut engine = Engine::new();
load_workspace(&mut engine, workdir)?;
engine
.load([(
lemma::SourceType::Dependency(raw_id.to_string()),
source_text.to_string(),
)])
.map_err(|load_err| {
for e in load_err.iter() {
eprintln!("{}", error_formatter::format_error(e, &load_err.sources));
}
anyhow::anyhow!(
"Planning fetched dependency failed ({} error(s))",
load_err.errors.len()
)
})?;
let dependency_destination_relative = relative_dependency_cache_path(attribute);
let destination_absolute = deps_dir.join(&dependency_destination_relative);
if let Some(parent_directory) = destination_absolute.parent() {
fs::create_dir_all(parent_directory)?;
}
fs::write(&destination_absolute, source_text)?;
eprintln!(
" fetched: {} -> {}",
attribute,
dependency_destination_relative.display()
);
Ok(())
}
async fn fetch_workspace_deps(
workdir: &Path,
registry: &dyn lemma::Registry,
force: bool,
) -> Result<()> {
let mut ctx = lemma::Context::new();
let mut sources: HashMap<lemma::SourceType, String> = HashMap::new();
let limits = lemma::ResourceLimits::default();
for entry in WalkDir::new(workdir) {
let entry = entry?;
if entry.path().extension().and_then(|s| s.to_str()) != Some("lemma") {
continue;
}
let path = entry.path();
let code = fs::read_to_string(path)?;
let source_type = lemma::SourceType::Path(Arc::new(path.to_path_buf()));
match lemma::parse(&code, source_type.clone(), &limits) {
Ok(result) => {
for (parsed_repo, specs) in &result.repositories {
let repository_arc = std::sync::Arc::clone(parsed_repo);
for spec in specs {
if let Err(e) =
ctx.insert_spec(std::sync::Arc::clone(&repository_arc), spec.clone())
{
eprintln!("warning: {}", e);
}
}
}
sources.insert(source_type, code);
}
Err(e) => {
sources.insert(source_type.clone(), code.clone());
eprintln!("{}", error_formatter::format_error(&e, &sources));
anyhow::bail!("Parse error in {}", path.display());
}
}
}
let local_workspace_sources: std::collections::HashSet<lemma::SourceType> =
sources.keys().cloned().collect();
if let Err(errs) =
lemma::resolve_registry_references(&mut ctx, &mut sources, registry, &limits).await
{
for e in &errs {
eprintln!("{}", error_formatter::format_error(e, &sources));
}
anyhow::bail!("Registry resolution failed ({} error(s))", errs.len());
}
let mut validate_engine = Engine::new();
for (source_id, code) in &sources {
if local_workspace_sources.contains(source_id) {
continue;
}
if let Err(load_err) = validate_engine.load([(source_id.clone(), code.clone())]) {
for e in load_err.iter() {
eprintln!("{}", error_formatter::format_error(e, &load_err.sources));
}
anyhow::bail!(
"Planning fetched deps failed ({} error(s))",
load_err.errors.len()
);
}
}
let deps_dir = lemma_deps_dir(workdir);
let mut existing_specs_by_name: HashMap<String, PathBuf> = HashMap::new();
let mut existing_content_by_path: HashMap<PathBuf, String> = HashMap::new();
if deps_dir.exists() {
for entry in WalkDir::new(&deps_dir) {
let entry = entry?;
if entry.path().extension().and_then(|s| s.to_str()) != Some("lemma") {
continue;
}
let path = entry.path().to_path_buf();
let content = fs::read_to_string(&path)?;
match lemma::parse(
&content,
lemma::SourceType::Path(Arc::new(path.clone())),
&limits,
) {
Ok(result) => {
for spec in result.flatten_specs() {
existing_specs_by_name.insert(spec.name.clone(), path.clone());
}
}
Err(e) => {
let mut m = std::collections::HashMap::new();
m.insert(
lemma::SourceType::Path(Arc::new(path.clone())),
content.clone(),
);
eprintln!(
"warning: ignoring invalid cached dependency {}:\n{}",
path.display(),
error_formatter::format_error(&e, &m)
);
}
}
existing_content_by_path.insert(path, content);
}
}
let mut fetched_count = 0u32;
let mut skipped_count = 0u32;
let mut removed: std::collections::HashSet<PathBuf> = std::collections::HashSet::new();
for (attribute, source_text) in &sources {
if local_workspace_sources.contains(attribute) {
continue;
}
let already_on_disk = existing_content_by_path.values().any(|c| c == source_text);
if already_on_disk && !force {
skipped_count += 1;
continue;
}
let new_specs = match lemma::parse(source_text, attribute.clone(), &limits) {
Ok(r) => r.into_flattened_specs(),
Err(e) => {
let mut m = std::collections::HashMap::new();
m.insert(attribute.clone(), source_text.clone());
eprintln!("{}", error_formatter::format_error(&e, &m));
anyhow::bail!("Parse error in registry dependency {}", attribute);
}
};
for spec in &new_specs {
if let Some(old_path) = existing_specs_by_name.get(&spec.name) {
if removed.contains(old_path) {
continue;
}
if !force {
anyhow::bail!(
"Dependency containing spec {} already exists in {}.\n\
Content has changed on the registry. Re-run with --force to overwrite.",
spec.name,
old_path.display()
);
}
fs::remove_file(old_path)?;
eprintln!(" removed: {}", old_path.display());
removed.insert(old_path.clone());
}
}
let registry_source_identifier_display = attribute.to_string();
let dependency_destination_relative =
relative_dependency_cache_path(®istry_source_identifier_display);
let destination_absolute = deps_dir.join(&dependency_destination_relative);
if let Some(parent_directory) = destination_absolute.parent() {
fs::create_dir_all(parent_directory)?;
}
fs::write(&destination_absolute, source_text)?;
fetched_count += 1;
eprintln!(
" fetched: {} -> {}",
registry_source_identifier_display,
dependency_destination_relative.display()
);
}
let plural = if fetched_count == 1 {
"dependency"
} else {
"dependencies"
};
if fetched_count == 0 && skipped_count == 0 {
eprintln!("No dependencies found.");
} else if fetched_count == 0 {
eprintln!("All dependencies are up to date. Use --force to overwrite.");
} else if skipped_count > 0 {
eprintln!(
"Fetched {} {} ({} already up to date).",
fetched_count, plural, skipped_count
);
} else {
eprintln!("Fetched {} {}.", fetched_count, plural);
}
Ok(())
}
fn make_fetch_registry() -> Box<dyn lemma::Registry> {
Box::new(lemma::LemmaBase::new())
}
fn load_workspace(engine: &mut Engine, workdir: &std::path::Path) -> Result<usize> {
let mut workspace_paths: Vec<std::path::PathBuf> = Vec::new();
let mut deps_paths: Vec<std::path::PathBuf> = Vec::new();
if workdir.is_file() {
workspace_paths.push(workdir.to_path_buf());
} else {
let deps_dir = lemma_deps_dir(workdir);
for entry in WalkDir::new(workdir) {
let entry = entry?;
if entry.path().extension().and_then(|s| s.to_str()) != Some("lemma") {
continue;
}
if entry.path().starts_with(&deps_dir) {
deps_paths.push(entry.path().to_path_buf());
} else {
workspace_paths.push(entry.path().to_path_buf());
}
}
}
let discovered_lemma_source_total: usize = if workdir.is_file() {
1
} else {
workspace_paths.len() + deps_paths.len()
};
let mut sources = Vec::with_capacity(deps_paths.len().saturating_add(workspace_paths.len()));
for dep_path in &deps_paths {
let dependency_id = dependency_identifier_from_dependency_path(workdir, dep_path);
let content = fs::read_to_string(dep_path)?;
sources.push((
lemma::SourceType::Dependency(dependency_id.to_string()),
content,
));
}
for path in &workspace_paths {
let content = fs::read_to_string(path)?;
sources.push((lemma::SourceType::Path(Arc::new(path.clone())), content));
}
if !sources.is_empty() {
if let Err(load_failures) = engine.load(sources) {
bail_workspace_load_errors(&load_failures)?;
}
}
Ok(discovered_lemma_source_total)
}
fn bail_workspace_load_errors(load_errors: &lemma::Errors) -> anyhow::Result<usize> {
let mut emitted_message_keys = std::collections::HashSet::new();
let unique_errors: Vec<_> = load_errors
.iter()
.filter(|report| emitted_message_keys.insert(report.message().to_string()))
.collect();
for report in &unique_errors {
eprintln!(
"{}",
error_formatter::format_error(report, &load_errors.sources)
);
}
anyhow::bail!("Workspace load failed ({} error(s))", unique_errors.len());
}
fn parse_rule_names(comma_separated_rules: &str) -> Vec<String> {
comma_separated_rules
.split(',')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect()
}
fn collect_lemma_paths(paths: &[PathBuf]) -> Result<Vec<PathBuf>, std::io::Error> {
let mut seen = std::collections::HashSet::new();
let mut result = Vec::new();
for path in paths {
if path.is_file() {
if path.extension().and_then(|e| e.to_str()) == Some("lemma") {
let canonical = path.canonicalize().unwrap_or_else(|_| path.clone());
if seen.insert(canonical.clone()) {
result.push(path.clone());
}
}
} else if path.is_dir() {
for entry_result in WalkDir::new(path) {
let entry = match entry_result {
Ok(e) => e,
Err(e) => {
eprintln!("warning: ignoring directory entry: {}", e);
continue;
}
};
let p = entry.path();
if p.is_file() && p.extension().and_then(|e| e.to_str()) == Some("lemma") {
if let Ok(canonical) = p.canonicalize() {
if seen.insert(canonical) {
result.push(p.to_path_buf());
}
} else if seen.insert(p.to_path_buf()) {
result.push(p.to_path_buf());
}
}
}
}
}
Ok(result)
}
fn format_command(paths: &[PathBuf], check: bool, stdout: bool) -> Result<()> {
let files = collect_lemma_paths(paths)?;
let mut any_changed = false;
let mut parse_errors = 0u32;
for file_path in &files {
let source = match fs::read_to_string(file_path) {
Ok(s) => s,
Err(e) => {
eprintln!("Error reading {}: {}", file_path.display(), e);
parse_errors += 1;
continue;
}
};
let formatted = match lemma::format_source(
&source,
lemma::SourceType::Path(std::sync::Arc::new(file_path.clone())),
) {
Ok(s) => s,
Err(e) => {
let mut m = std::collections::HashMap::new();
m.insert(
lemma::SourceType::Path(std::sync::Arc::new(file_path.clone())),
source.clone(),
);
eprintln!("{}", error_formatter::format_error(&e, &m));
parse_errors += 1;
continue;
}
};
if stdout {
print!("{}", formatted);
continue;
}
if source == formatted {
continue;
}
any_changed = true;
if check {
eprintln!("Would reformat: {}", file_path.display());
} else if let Err(e) = fs::write(file_path, &formatted) {
eprintln!("Error writing {}: {}", file_path.display(), e);
parse_errors += 1;
} else {
eprintln!("Formatted: {}", file_path.display());
}
}
if parse_errors > 0 {
std::process::exit(1);
}
if check && any_changed {
std::process::exit(1);
}
Ok(())
}