use clap::Parser;
use serde::Serialize;
use serde_json::Value;
#[derive(Parser, Debug)]
#[command(name = "splice")]
#[command(
author,
version,
about,
long_about = "
Splice: Span-safe refactoring kernel for Rust.
Query Commands (Magellan-delegated):
status, find, refs, files, query Query code graph database
Graph Algorithm Commands:
reachable, dead-code, cycles Analyze code structure
condense, slice Impact analysis and slicing
Edit Commands:
delete, patch, plan, apply-files Modify code with span safety
Export Commands:
log, undo, export Export and restore operations
Validation Commands:
explain, search, get Validate and explain code
Use 'splice help <command>' for more information on a specific command.
Options:
-v, --verbose Enable verbose logging
-o, --output <FORMAT> Output format (human, json, pretty)
--json Output JSON (deprecated: use --output json)
--strict Enable strict pre-verification
-h, --help Print help
-V, --version Print version
"
)]
#[command(subcommand_required = true)]
pub struct Cli {
#[command(subcommand)]
pub command: Commands,
#[arg(short, long, global = true)]
pub verbose: bool,
#[arg(short, long, global = true, value_enum, default_value_t = OutputFormat::Human)]
pub output: OutputFormat,
#[arg(long, global = true, hide = true)]
json: bool,
#[arg(long, global = true)]
pub strict: bool,
#[arg(long, global = true, hide = true)]
pub skip_pre_verify: bool,
}
#[derive(clap::Subcommand, Debug)]
pub enum Commands {
#[command(display_order = 200)]
Delete {
#[arg(short, long)]
file: std::path::PathBuf,
#[arg(short, long)]
symbol: String,
#[arg(short, long)]
kind: Option<SymbolKind>,
#[arg(long, value_name = "MODE")]
analyzer: Option<AnalyzerMode>,
#[arg(long, value_name = "PATH")]
analyzer_binary: Option<std::path::PathBuf>,
#[arg(long, value_name = "LANG")]
language: Option<Language>,
#[arg(short = 'A', long, value_name = "N", default_value = "0")]
context_after: usize,
#[arg(short = 'B', long, value_name = "N", default_value = "0")]
context_before: usize,
#[arg(short = 'C', long, value_name = "N", default_value = "3")]
context: usize,
#[arg(long)]
create_backup: bool,
#[arg(long)]
relationships: bool,
#[arg(short = 'n', long = "dry-run")]
dry_run: bool,
#[arg(short = 'U', long, value_name = "N", default_value = "3")]
unified: usize,
#[arg(long)]
operation_id: Option<String>,
#[arg(long)]
metadata: Option<String>,
#[arg(long)]
snapshot_before: bool,
},
#[command(display_order = 201)]
Patch {
#[arg(short = 'f', long, required_unless_present = "batch")]
file: Option<std::path::PathBuf>,
#[arg(short = 's', long, required_unless_present = "batch")]
symbol: Option<String>,
#[arg(short, long, conflicts_with = "batch")]
kind: Option<SymbolKind>,
#[arg(long, value_name = "MODE")]
analyzer: Option<AnalyzerMode>,
#[arg(long, value_name = "PATH")]
analyzer_binary: Option<std::path::PathBuf>,
#[arg(
short = 'w',
long = "with",
value_name = "FILE",
required_unless_present = "batch"
)]
with_: Option<std::path::PathBuf>,
#[arg(long, value_name = "LANG")]
language: Option<Language>,
#[arg(long, value_name = "FILE")]
batch: Option<std::path::PathBuf>,
#[arg(short = 'A', long, value_name = "N", default_value = "0")]
context_after: usize,
#[arg(short = 'B', long, value_name = "N", default_value = "0")]
context_before: usize,
#[arg(short = 'C', long, value_name = "N", default_value = "3")]
context_both: usize,
#[arg(
short = 'n',
long = "dry-run",
alias = "preview",
conflicts_with = "batch"
)]
preview: bool,
#[arg(short = 'U', long, value_name = "N", default_value = "3")]
unified: usize,
#[arg(long)]
create_backup: bool,
#[arg(long)]
relationships: bool,
#[arg(long)]
operation_id: Option<String>,
#[arg(long)]
metadata: Option<String>,
#[arg(short = 'd', long, value_name = "FILE")]
db: Option<std::path::PathBuf>,
#[arg(long)]
snapshot_before: bool,
#[arg(long, requires = "preview")]
impact_graph: bool,
},
Plan {
#[arg(short, long)]
file: std::path::PathBuf,
#[arg(long)]
operation_id: Option<String>,
#[arg(long)]
metadata: Option<String>,
},
Undo {
#[arg(short, long)]
manifest: std::path::PathBuf,
},
ApplyFiles {
#[arg(short, long)]
glob: String,
#[arg(short, long)]
find: String,
#[arg(short, long)]
replace: String,
#[arg(long, value_name = "LANG")]
language: Option<Language>,
#[arg(short = 'A', long, value_name = "N", default_value = "0")]
context_after: usize,
#[arg(short = 'B', long, value_name = "N", default_value = "0")]
context_before: usize,
#[arg(short = 'C', long, value_name = "N", default_value = "3")]
context_both: usize,
#[arg(long)]
no_validate: bool,
#[arg(long)]
create_backup: bool,
#[arg(long)]
operation_id: Option<String>,
#[arg(long)]
metadata: Option<String>,
},
#[command(display_order = 104)]
Query {
#[arg(short, long)]
db: std::path::PathBuf,
#[arg(short, long)]
label: Vec<String>,
#[arg(long)]
file: Option<String>,
#[arg(short = 'A', long, value_name = "N", default_value = "0")]
context_after: usize,
#[arg(short = 'B', long, value_name = "N", default_value = "0")]
context_before: usize,
#[arg(short = 'C', long, value_name = "N", default_value = "3")]
context_both: usize,
#[arg(long)]
list: bool,
#[arg(long)]
count: bool,
#[arg(long)]
show_code: bool,
#[arg(long)]
relationships: bool,
#[arg(long)]
expand: bool,
#[arg(long = "expand-level", value_name = "N", default_value = "1")]
expand_level: usize,
},
#[command(display_order = 105)]
Get {
#[arg(short, long)]
db: std::path::PathBuf,
#[arg(short, long)]
file: std::path::PathBuf,
#[arg(long)]
start: usize,
#[arg(long)]
end: usize,
#[arg(short = 'A', long, value_name = "N", default_value = "0")]
context_after: usize,
#[arg(short = 'B', long, value_name = "N", default_value = "0")]
context_before: usize,
#[arg(short = 'C', long, value_name = "N", default_value = "3")]
context_both: usize,
#[arg(long)]
relationships: bool,
#[arg(long)]
expand: bool,
#[arg(long = "expand-level", value_name = "N", default_value = "1")]
expand_level: usize,
},
#[command(display_order = 300)]
Log {
#[arg(short, long)]
operation_type: Option<String>,
#[arg(short, long)]
status: Option<String>,
#[arg(long)]
after: Option<String>,
#[arg(long)]
before: Option<String>,
#[arg(short, long, default_value = "20")]
limit: usize,
#[arg(long, default_value = "0")]
offset: usize,
#[arg(short, long)]
execution_id: Option<String>,
#[arg(short, long)]
json: bool,
#[arg(long)]
stats: bool,
},
#[command(display_order = 400)]
Explain {
#[arg(short, long, value_name = "CODE")]
code: String,
},
#[command(display_order = 401)]
Search {
#[arg(short, long)]
pattern: String,
#[arg(long, value_name = "PATH", default_value = ".")]
path: std::path::PathBuf,
#[arg(long, value_name = "LANG")]
language: Option<Language>,
#[arg(short = 'g', long, value_name = "GLOB")]
glob: Option<String>,
#[arg(short = 'A', long, value_name = "N", default_value = "0")]
context_after: usize,
#[arg(short = 'B', long, value_name = "N", default_value = "0")]
context_before: usize,
#[arg(short = 'C', long, value_name = "N", default_value = "2")]
context_both: usize,
#[arg(long, requires = "replace")]
apply: bool,
#[arg(short = 'r', long, value_name = "TEXT")]
replace: Option<String>,
#[arg(long)]
json: bool,
},
#[command(display_order = 100)]
Status {
#[arg(short, long)]
db: std::path::PathBuf,
#[arg(long, default_value = "false")]
detect_backend: bool,
},
#[command(display_order = 101)]
Find {
#[arg(short, long)]
db: std::path::PathBuf,
#[arg(short, long, conflicts_with = "symbol_id")]
name: Option<String>,
#[arg(long, conflicts_with = "name")]
symbol_id: Option<String>,
#[arg(short, long)]
ambiguous: bool,
#[arg(short, long, value_enum, default_value_t = OutputFormat::Human)]
output: OutputFormat,
},
#[command(display_order = 102)]
Refs {
#[arg(short, long)]
db: std::path::PathBuf,
#[arg(short, long)]
name: String,
#[arg(short, long)]
path: std::path::PathBuf,
#[arg(long, value_enum, default_value_t = CallDirection::Both)]
direction: CallDirection,
#[arg(short, long, value_enum, default_value_t = OutputFormat::Human)]
output: OutputFormat,
#[arg(long)]
impact_graph: bool,
},
#[command(display_order = 103)]
Files {
#[arg(short, long)]
db: std::path::PathBuf,
#[arg(long)]
symbols: bool,
#[arg(short, long, value_enum, default_value_t = OutputFormat::Human)]
output: OutputFormat,
},
#[command(display_order = 106)]
Export {
#[arg(short, long)]
db: std::path::PathBuf,
#[arg(short, long, value_enum, default_value_t = ExportFormat::Json)]
format: ExportFormat,
#[arg(long)]
file: Option<std::path::PathBuf>,
},
#[command(display_order = 107)]
MigrateDb {
#[arg(short, long, default_value = ".magellan/magellan.db")]
db_path: std::path::PathBuf,
#[arg(long, default_value = "true")]
backup: bool,
#[arg(long)]
dry_run: bool,
},
#[command(display_order = 110)]
Rename {
#[arg(short, long, conflicts_with = "name")]
symbol: Option<String>,
#[arg(long, conflicts_with = "symbol")]
name: Option<String>,
#[arg(short, long)]
file: Option<std::path::PathBuf>,
#[arg(short, long)]
to: String,
#[arg(short, long, default_value = ".magellan/magellan.db")]
db: std::path::PathBuf,
#[arg(short = 'n', long = "dry-run")]
preview: bool,
#[arg(long)]
proof: bool,
#[arg(long)]
backup_dir: Option<std::path::PathBuf>,
#[arg(long)]
no_backup: bool,
#[arg(long, default_value = "true")]
create_backup: bool,
#[arg(long)]
snapshot_before: bool,
#[arg(long, requires = "preview")]
impact_graph: bool,
},
#[command(display_order = 111)]
Reachable {
#[arg(short, long)]
symbol: String,
#[arg(short, long)]
path: std::path::PathBuf,
#[arg(short, long, default_value = ".magellan/magellan.db")]
db: std::path::PathBuf,
#[arg(long, value_enum, default_value_t = ReachabilityDirection::Forward)]
direction: ReachabilityDirection,
#[arg(long, default_value = "10")]
max_depth: usize,
#[arg(short, long, value_enum, default_value_t = OutputFormat::Human)]
output: OutputFormat,
#[arg(long)]
impact_graph: bool,
},
#[command(display_order = 112)]
DeadCode {
#[arg(short, long)]
entry: String,
#[arg(short, long)]
path: std::path::PathBuf,
#[arg(short, long, default_value = ".magellan/magellan.db")]
db: std::path::PathBuf,
#[arg(long)]
exclude_public: bool,
#[arg(long, default_value = "true")]
group_by_file: bool,
#[arg(short, long, value_enum, default_value_t = OutputFormat::Human)]
output: OutputFormat,
},
#[command(display_order = 113)]
Cycles {
#[arg(short, long, default_value = ".magellan/magellan.db")]
db: std::path::PathBuf,
#[arg(short, long)]
symbol: Option<String>,
#[arg(short, long)]
path: Option<std::path::PathBuf>,
#[arg(short, long, default_value = "100")]
max_cycles: usize,
#[arg(long, default_value = "true")]
show_members: bool,
#[arg(short, long, value_enum, default_value_t = OutputFormat::Human)]
output: OutputFormat,
},
#[command(display_order = 114)]
Condense {
#[arg(short, long, default_value = ".magellan/magellan.db")]
db: std::path::PathBuf,
#[arg(long, default_value = "true")]
show_members: bool,
#[arg(long)]
show_levels: bool,
#[arg(short, long, value_enum, default_value_t = OutputFormat::Human)]
output: OutputFormat,
},
#[command(display_order = 115)]
Slice {
#[arg(short, long)]
target: String,
#[arg(short, long)]
path: std::path::PathBuf,
#[arg(short, long, default_value = ".magellan/magellan.db")]
db: std::path::PathBuf,
#[arg(long, value_enum, default_value_t = SliceDirection::Forward)]
direction: SliceDirection,
#[arg(long)]
max_depth: Option<usize>,
#[arg(short, long, value_enum, default_value_t = OutputFormat::Human)]
output: OutputFormat,
},
#[command(display_order = 116)]
ValidateProof {
#[arg(short, long)]
proof: std::path::PathBuf,
#[arg(short, long, value_enum, default_value_t = OutputFormat::Human)]
output: OutputFormat,
},
#[command(display_order = 117)]
Verify {
#[arg(short = 'b', long)]
before: std::path::PathBuf,
#[arg(short = 'a', long)]
after: std::path::PathBuf,
#[arg(long)]
detailed: bool,
#[arg(short, long, value_enum, default_value_t = OutputFormat::Human)]
output: OutputFormat,
},
#[command(display_order = 250)]
Batch {
#[arg(short = 'f', long)]
spec: std::path::PathBuf,
#[arg(short = 'd', long)]
db: Option<std::path::PathBuf>,
#[arg(short = 'n', long = "dry-run")]
dry_run: bool,
#[arg(long = "continue-on-error")]
continue_on_error: bool,
#[arg(long, value_enum, default_value_t = CliRollbackMode::Auto)]
rollback: CliRollbackMode,
#[arg(long, value_name = "MODE")]
analyzer: Option<AnalyzerMode>,
#[arg(long, value_name = "PATH")]
analyzer_binary: Option<std::path::PathBuf>,
},
#[command(display_order = 105)]
Create {
#[arg(short, long)]
file: std::path::PathBuf,
#[arg(short = 'V', long)]
validate_only: bool,
#[arg(short = 'm', long)]
with_mod: bool,
#[arg(short, long, default_value = ".")]
workspace: std::path::PathBuf,
},
#[command(display_order = 119)]
Complete {
#[arg(short, long)]
file: std::path::PathBuf,
#[arg(short, long)]
line: usize,
#[arg(short, long)]
column: usize,
#[arg(short, long, default_value = "10")]
max_results: usize,
#[arg(short, long, default_value = ".magellan/splice.db")]
db: std::path::PathBuf,
},
#[command(display_order = 120, subcommand)]
Snapshots(SnapshotsCommands),
}
#[derive(clap::Subcommand, Debug, Clone)]
pub enum SnapshotsCommands {
List {
#[arg(short, long)]
operation: Option<String>,
#[arg(short = 'n', long)]
limit: Option<usize>,
#[arg(long)]
disk_usage: bool,
#[arg(short, long, value_enum, default_value_t = OutputFormat::Human)]
output: OutputFormat,
},
Delete {
#[arg(short, long)]
id: String,
#[arg(long)]
force: bool,
},
Cleanup {
#[arg(short = 'k', long, default_value = "10")]
keep: usize,
#[arg(long)]
dry_run: bool,
},
}
#[derive(clap::ValueEnum, Debug, Clone, Copy)]
pub enum SymbolKind {
Function,
Method,
Class,
Struct,
Interface,
Enum,
Trait,
Impl,
Module,
Variable,
Constructor,
TypeAlias,
}
#[derive(clap::ValueEnum, Debug, Clone, Copy)]
pub enum Language {
Rust,
Python,
C,
Cpp,
Java,
JavaScript,
TypeScript,
}
#[derive(clap::ValueEnum, Debug, Clone, Copy, PartialEq, Eq)]
pub enum OutputFormat {
Human,
Json,
Pretty,
}
#[derive(clap::ValueEnum, Debug, Clone, Copy, PartialEq, Eq)]
pub enum CallDirection {
In,
Out,
Both,
}
#[derive(clap::ValueEnum, Debug, Clone, Copy, PartialEq, Eq)]
pub enum ReachabilityDirection {
Forward,
Reverse,
Both,
}
#[derive(clap::ValueEnum, Debug, Clone, Copy, PartialEq, Eq)]
pub enum SliceDirection {
Forward,
Backward,
}
#[derive(clap::ValueEnum, Default, Debug, Clone, Copy, PartialEq, Eq)]
pub enum ExportFormat {
#[default]
Json,
Jsonl,
Csv,
}
impl OutputFormat {
pub fn is_json(self) -> bool {
matches!(self, Self::Json | Self::Pretty)
}
pub fn format_json<T: serde::Serialize>(&self, value: &T) -> Result<String, String> {
match self {
Self::Json => serde_json::to_string(value).map_err(|e| e.to_string()),
Self::Pretty => serde_json::to_string_pretty(value).map_err(|e| e.to_string()),
Self::Human => Err("Human format requested but format_json called".to_string()),
}
}
}
impl Language {
pub fn as_str(&self) -> &'static str {
match self {
Language::Rust => "rust",
Language::Python => "python",
Language::C => "c",
Language::Cpp => "cpp",
Language::Java => "java",
Language::JavaScript => "javascript",
Language::TypeScript => "typescript",
}
}
pub fn to_symbol_language(self) -> crate::symbol::Language {
match self {
Language::Rust => crate::symbol::Language::Rust,
Language::Python => crate::symbol::Language::Python,
Language::C => crate::symbol::Language::C,
Language::Cpp => crate::symbol::Language::Cpp,
Language::Java => crate::symbol::Language::Java,
Language::JavaScript => crate::symbol::Language::JavaScript,
Language::TypeScript => crate::symbol::Language::TypeScript,
}
}
}
#[derive(clap::ValueEnum, Debug, Clone, Copy)]
pub enum AnalyzerMode {
Off,
Os,
Path,
}
#[derive(clap::ValueEnum, Debug, Clone, Copy, PartialEq, Eq)]
pub enum CliRollbackMode {
Auto,
Never,
Always,
}
pub fn parse_args() -> Cli {
Cli::parse()
}
impl Cli {
pub fn json_output(&self) -> bool {
if self.json {
return true;
}
self.output.is_json()
}
pub fn output_format(&self) -> OutputFormat {
if self.json {
return OutputFormat::Json;
}
self.output
}
}
#[derive(Serialize)]
pub struct CliSuccessPayload {
pub status: &'static str,
pub message: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub data: Option<Value>,
#[serde(skip)]
pub already_emitted: bool,
#[serde(skip)]
pub has_pending_changes: bool,
}
impl CliSuccessPayload {
pub fn message_only(message: String) -> Self {
Self {
status: "ok",
message,
data: None,
already_emitted: false,
has_pending_changes: false,
}
}
pub fn with_data(message: String, data: Value) -> Self {
Self {
status: "ok",
message,
data: Some(data),
already_emitted: false,
has_pending_changes: false,
}
}
pub fn already_emitted(mut self) -> Self {
self.already_emitted = true;
self
}
pub fn with_pending_changes(mut self) -> Self {
self.has_pending_changes = true;
self
}
}
#[derive(Serialize)]
pub struct CliErrorPayload {
pub status: &'static str,
pub error: ErrorDetails,
}
#[derive(Serialize)]
pub struct ErrorDetails {
pub kind: &'static str,
pub message: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub symbol: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub file: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub hint: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub diagnostics: Option<Vec<DiagnosticPayload>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub error_code: Option<crate::ErrorCode>,
#[serde(skip_serializing_if = "Option::is_none")]
pub explain_command: Option<String>,
}
impl CliErrorPayload {
pub fn from_error(error: &crate::SpliceError) -> Self {
let symbol = error.symbol().map(|s| s.to_string());
let file = error
.file_path()
.and_then(|p| p.to_str().map(|s| s.to_string()));
let hint = error.hint().map(|h| h.to_string());
let diagnostics = {
let diagnostics = error.diagnostics();
if diagnostics.is_empty() {
None
} else {
Some(
diagnostics
.into_iter()
.map(DiagnosticPayload::from)
.collect(),
)
}
};
let error_code =
crate::error_codes::SpliceErrorCode::from_splice_error(error).map(|splice_code| {
let (file, line, column) = error.location();
crate::ErrorCode::from_splice_code(splice_code, file, line, column)
});
let explain_command = error_code
.as_ref()
.map(|ec| format!("splice explain --code {}", ec.code));
CliErrorPayload {
status: "error",
error: ErrorDetails {
kind: error.kind(),
message: error.to_string(),
symbol,
file,
hint,
diagnostics,
error_code,
explain_command,
},
}
}
}
#[derive(Serialize)]
pub struct DiagnosticPayload {
pub tool: String,
pub level: String,
pub message: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub file: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub line: Option<usize>,
#[serde(skip_serializing_if = "Option::is_none")]
pub column: Option<usize>,
#[serde(skip_serializing_if = "Option::is_none")]
pub code: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub note: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tool_path: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tool_version: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub remediation: Option<String>,
}
impl From<crate::error::Diagnostic> for DiagnosticPayload {
fn from(diag: crate::error::Diagnostic) -> Self {
DiagnosticPayload {
tool: diag.tool,
level: diag.level.as_str().to_string(),
message: diag.message,
file: diag
.file
.as_ref()
.and_then(|p| p.to_str().map(|s| s.to_string())),
line: diag.line,
column: diag.column,
code: diag.code,
note: diag.note,
tool_path: diag
.tool_path
.as_ref()
.and_then(|p| p.to_str().map(|s| s.to_string())),
tool_version: diag.tool_version,
remediation: diag.remediation,
}
}
}
#[cfg(test)]
mod tests;
pub use crate::output::{
CallExport, ExportData, ExportResponse, FileExport, FilesResponse, FindResponse,
MagellanCallReference, MagellanFileMetadata, MagellanSpan, MagellanSymbol, ReferenceExport,
RefsResponse, StatusResponse, SymbolExport, EXPORT_SCHEMA_VERSION,
};