use std::path::PathBuf;
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum Error {
#[error("arcform.yaml not found in current directory")]
ManifestNotFound,
#[error("failed to read {path}: {source}")]
FileRead {
path: PathBuf,
source: std::io::Error,
},
#[error("failed to parse arcform.yaml: {0}")]
ManifestParse(#[from] serde_yaml::Error),
#[error("invalid manifest: {0}")]
ManifestValidation(String),
#[error("edit: {path}: {detail}")]
EditTarget { path: String, detail: String },
#[error("a spec already exists at {0} — edit it rather than re-creating it")]
SpecExists(PathBuf),
#[error("a sql file already exists at {0} — a recorded step never overwrites a model")]
GeneratedSqlExists(PathBuf),
#[error(
"step '{step}': {path} is hand-authored (no generated marker) and will not be \
rewritten — record a new step downstream of it instead"
)]
HandAuthoredSql { step: String, path: PathBuf },
#[error("step '{step}': sql file not found: {path}")]
SqlFileNotFound { step: String, path: PathBuf },
#[error(
"history: no store root (set ARCFORM_HISTORY_DIR to a writable directory, \
or ensure a home directory exists)"
)]
HistoryRootMissing,
#[error(
"database: no data folder for a Protocol that names no db: (set ARCFORM_DB_DIR \
to a writable directory, ensure a home directory exists, or name a db: in arcform.yaml)"
)]
DbRootMissing,
#[error(
"database: name '{name}' is not a plain file name, so arc cannot keep its database \
in the data folder (give the Protocol a name with no '/', or name a db: in arcform.yaml)"
)]
DbNameNotAFileName { name: String },
#[error("database: could not write {path} in arc's data folder: {source}")]
DbFolderWrite {
path: PathBuf,
source: std::io::Error,
},
#[error("history: no entry '{id}' for this spec (see `arc history list`)")]
HistoryEntryNotFound { id: String },
#[error("history: {way:?} cannot name the way arc was reached: {detail}")]
HistoryWay { way: String, detail: String },
#[error("engine '{engine}' not found on PATH or not executable")]
EngineNotFound { engine: String },
#[error("engine version mismatch: requires {required}, found {found}")]
VersionMismatch { required: String, found: String },
#[error(
"{var} names '{}', which is not a usable DuckDB executable ({reason}); \
point it at one, or unset it to use the `duckdb` on PATH",
path.display()
)]
EngineBinInvalid {
var: &'static str,
path: PathBuf,
reason: String,
},
#[error(
"engine DuckDB {found} is outside the versions arc is tested on ({range}); \
use a DuckDB in that range, or set {override_var}=1 to run on {found} at your own risk"
)]
UntestedEngine {
found: String,
range: &'static str,
override_var: &'static str,
},
#[error(
"engine DuckDB {found} is older than {oldest}, the oldest DuckDB arc accepts ({range}): \
arc links DuckDB 2.0, and a database it creates cannot be opened by DuckDB 1.x; \
use DuckDB {oldest} or later, or install arc 0.2.1, the release for DuckDB 1.5 (`cargo install arcform \
--version 0.2.1`)"
)]
EngineTooOld {
found: String,
oldest: &'static str,
range: &'static str,
},
#[error(
"this Protocol's SQL installs a DuckDB extension arc has not vetted, so no step was run:\n{}\n\
A step or hook may install a community extension on the vetted list, FROM community. \
The list, and what this check does not read, are at {}",
indented(refusals),
crate::engine::VETTED_EXTENSIONS_DOC
)]
ExtensionRefused { refusals: Vec<String> },
#[error(
"arc could not confirm that each extension this Protocol installs is the build its arcform.yaml pins, so no step or hook was run:\n{}\n\
A pin sits under the extensions: key of arcform.yaml. See {}",
indented(refusals),
crate::engine::VETTED_EXTENSIONS_DOC
)]
ExtensionPinRefused { refusals: Vec<String> },
#[error(
"{place} was refused and did not run: its SQL file, read again just before it ran, installs a DuckDB extension arc has not vetted:\n{}\n\
A step or hook may install a community extension on the vetted list, FROM community. \
The list, and what this check does not read, are at {}",
indented(refusals),
crate::engine::VETTED_EXTENSIONS_DOC
)]
ExtensionRefusedBeforeItRan {
place: String,
step: Option<String>,
refusals: Vec<String>,
},
#[error(
"{place} ({file}) was refused and did not run: arc could not read the tokens of its SQL from DuckDB: {reason}\n\
arc reads each SQL file's tokens from the DuckDB that runs it, to check what the file installs, and runs no file it could not read. See {}",
crate::engine::VETTED_EXTENSIONS_DOC
)]
SqlNotRead {
place: String,
step: Option<String>,
file: String,
reason: String,
},
#[error(
"{} No step was run. {}",
crate::engine::no_tokenizer_cause(which, error),
crate::engine::release_install_fix()
)]
NoTokenizer { which: String, error: String },
#[error(
"{place} was refused and did not run: arc could not confirm that each extension its SQL file, read again just before it ran, installs is the build its arcform.yaml pins:\n{}\n\
A pin sits under the extensions: key of arcform.yaml. See {}",
indented(refusals),
crate::engine::VETTED_EXTENSIONS_DOC
)]
ExtensionPinRefusedBeforeItRan {
place: String,
step: Option<String>,
refusals: Vec<String>,
},
#[error(
"arc upgrade pins a community extension on the vetted list, and {name} is not on it, so arcform.yaml was not changed. The list is at {}",
crate::engine::VETTED_EXTENSIONS_DOC
)]
ExtensionNotVetted { name: String },
#[error("arc could not pin {name}, so arcform.yaml was not changed: {reason}")]
ExtensionUpgradeFailed { name: String, reason: String },
#[error(
"an extension this Protocol installs changed during the run, so the run failed:\n{}\n\
A step that runs FORCE INSTALL, UPDATE EXTENSIONS or a command can replace an installed extension, \
and steps after it may have loaded the file. See {}",
indented(changes),
crate::engine::VETTED_EXTENSIONS_DOC
)]
ExtensionChanged { changes: Vec<String> },
#[error("DuckDB could not {what}: {reason}")]
EngineQuery { what: String, reason: String },
#[error("{0}")]
SqlReader(String),
#[error("step '{step}' failed (exit code {code}):\n{stderr}")]
StepFailed {
step: String,
code: i32,
stderr: String,
},
#[error("step '{step}' failed: {source}")]
StepExecution {
step: String,
source: std::io::Error,
},
#[error("project directory already exists: {0}")]
ProjectExists(PathBuf),
#[error(
"dependency order violation: step '{reader}' reads asset '{asset}' but '{asset}' is produced by step '{producer}' which runs after it"
)]
DependencyOrder {
reader: String,
asset: String,
producer: String,
},
#[error(
"precondition error for step '{step}': command '{command}' failed to execute: {detail}"
)]
Precondition {
step: String,
command: String,
detail: String,
},
#[error("precondition error for step '{step}': tool {tool} could not be identified: {detail}")]
ToolPrecondition {
step: String,
tool: String,
detail: String,
},
#[error("missing required parameter '{name}' (no default, not in dotenv or CLI)")]
MissingParam { name: String },
#[error("step '{step}' timed out")]
StepTimeout { step: String },
#[error("pipeline timeout after {elapsed_sec:.1}s — step '{step}' was running")]
PipelineTimeout { step: String, elapsed_sec: f64 },
#[error("state backend error: {0}")]
StateBackend(String),
#[allow(dead_code)]
#[error("registry: failed to fetch index from {url}: {detail}")]
RegistryIndexFetch { url: String, detail: String },
#[error("registry: failed to parse index: {detail}")]
RegistryIndexParse { detail: String },
#[error("registry: unknown entry '{query}' (try `arc registry list`)")]
RegistryUnknownEntry { query: String },
#[error("registry: malformed query '{query}' (expected `<name>` or `<owner>/<name>`)")]
RegistryAmbiguousQuery { query: String },
#[error("registry: transport error: {detail}")]
RegistryTransport { detail: String },
#[error("registry: cache I/O at {path}: {source}")]
RegistryCacheIo {
path: PathBuf,
source: std::io::Error,
},
#[error(
"registry: cache root unavailable (set ARCFORM_REGISTRY_CACHE to a writable directory)"
)]
RegistryCacheRootMissing,
#[error("registry: '{feature}' is not implemented in v1")]
RegistryUnimplemented { feature: String },
#[error("{0}")]
Io(#[from] std::io::Error),
}
impl Error {
pub(crate) fn exit_code(&self) -> i32 {
match self {
Error::StepFailed { .. }
| Error::StepExecution { .. }
| Error::Precondition { .. }
| Error::ToolPrecondition { .. }
| Error::StepTimeout { .. }
| Error::PipelineTimeout { .. }
| Error::ExtensionChanged { .. }
| Error::ExtensionRefusedBeforeItRan { .. }
| Error::ExtensionPinRefusedBeforeItRan { .. }
| Error::SqlNotRead { .. }
| Error::NoTokenizer { .. } => 2,
_ => 1,
}
}
pub(crate) fn refused_before_it_ran(&self) -> bool {
matches!(
self,
Error::ExtensionRefusedBeforeItRan { .. }
| Error::ExtensionPinRefusedBeforeItRan { .. }
| Error::SqlNotRead { .. }
)
}
}
pub type Result<T> = std::result::Result<T, Error>;
fn indented(lines: &[String]) -> String {
lines
.iter()
.map(|line| format!(" {line}"))
.collect::<Vec<_>>()
.join("\n")
}
#[cfg(test)]
mod format_tests {
use super::*;
use std::io;
use std::path::PathBuf;
fn assert_single_line_registry(err: &Error) {
let s = err.to_string();
assert!(!s.contains('\n'), "Display must be single-line: {:?}", s);
assert!(
s.starts_with("registry:"),
"expected `registry:` prefix, got: {:?}",
s
);
}
#[test]
fn registry_index_fetch() {
let e = Error::RegistryIndexFetch {
url: "https://example/index.yaml".into(),
detail: "boom".into(),
};
assert_single_line_registry(&e);
}
#[test]
fn registry_index_parse() {
let e = Error::RegistryIndexParse {
detail: "bad yaml".into(),
};
assert_single_line_registry(&e);
}
#[test]
fn registry_unknown_entry() {
let e = Error::RegistryUnknownEntry {
query: "nope".into(),
};
assert_single_line_registry(&e);
assert!(e.to_string().contains("nope"));
}
#[test]
fn registry_ambiguous_query() {
let e = Error::RegistryAmbiguousQuery {
query: "//bad".into(),
};
assert_single_line_registry(&e);
}
#[test]
fn registry_transport() {
let e = Error::RegistryTransport {
detail: "tarball walked outside <dest>".into(),
};
assert_single_line_registry(&e);
}
#[test]
fn registry_cache_io() {
let e = Error::RegistryCacheIo {
path: PathBuf::from("/tmp/cache/index.yaml"),
source: io::Error::new(io::ErrorKind::PermissionDenied, "denied"),
};
assert_single_line_registry(&e);
}
#[test]
fn registry_cache_root_missing() {
let e = Error::RegistryCacheRootMissing;
assert_single_line_registry(&e);
assert!(
e.to_string().contains("ARCFORM_REGISTRY_CACHE"),
"remediation env var must appear: {:?}",
e.to_string()
);
}
#[test]
fn history_root_missing_names_the_env_var() {
let e = Error::HistoryRootMissing;
let s = e.to_string();
assert!(!s.contains('\n'), "Display must be single-line: {:?}", s);
assert!(s.starts_with("history:"), "family prefix: {:?}", s);
assert!(
s.contains("ARCFORM_HISTORY_DIR"),
"remediation env var must appear: {:?}",
s
);
}
#[test]
fn db_name_not_a_file_name_names_the_name_and_both_remedies() {
let s = Error::DbNameNotAFileName {
name: "../shared".into(),
}
.to_string();
assert!(!s.contains('\n'), "Display must be single-line: {:?}", s);
assert!(s.starts_with("database:"), "family prefix: {:?}", s);
assert!(s.contains("'../shared'"), "the name: {:?}", s);
assert!(s.contains("db:"), "the db: remedy: {:?}", s);
}
#[test]
fn db_folder_write_names_the_path_and_the_cause() {
let s = Error::DbFolderWrite {
path: PathBuf::from("/data/k/protocol-path"),
source: std::io::Error::other("Is a directory"),
}
.to_string();
assert!(!s.contains('\n'), "Display must be single-line: {:?}", s);
assert!(s.starts_with("database:"), "family prefix: {:?}", s);
assert!(s.contains("/data/k/protocol-path"), "the path: {:?}", s);
assert!(s.contains("Is a directory"), "the cause: {:?}", s);
}
#[test]
fn db_root_missing_names_the_env_var_and_the_db_line() {
let s = Error::DbRootMissing.to_string();
assert!(!s.contains('\n'), "Display must be single-line: {:?}", s);
assert!(s.starts_with("database:"), "family prefix: {:?}", s);
assert!(s.contains("ARCFORM_DB_DIR"), "remediation env var: {:?}", s);
assert!(s.contains("db:"), "the other remedy, a db: line: {:?}", s);
}
#[test]
fn history_entry_not_found_names_the_entry_and_the_listing() {
let e = Error::HistoryEntryNotFound {
id: "1700000000000-000-save".into(),
};
let s = e.to_string();
assert!(!s.contains('\n'), "Display must be single-line: {:?}", s);
assert!(s.starts_with("history:"), "family prefix: {:?}", s);
assert!(s.contains("1700000000000-000-save"));
assert!(s.contains("arc history list"));
}
#[test]
fn history_way_names_the_word_and_why_on_one_line() {
let e = Error::HistoryWay {
way: "a\nb".into(),
detail: "'\\n' is not an ASCII letter, a digit, `-` or `_`".into(),
};
let s = e.to_string();
assert!(!s.contains('\n'), "Display must be single-line: {:?}", s);
assert!(s.starts_with("history:"), "family prefix: {:?}", s);
assert!(s.contains(r#""a\nb""#), "the word, escaped: {:?}", s);
assert!(s.contains("is not an ASCII letter"), "why: {:?}", s);
}
#[test]
fn registry_unimplemented() {
let e = Error::RegistryUnimplemented {
feature: "--latest rolling resolution".into(),
};
assert_single_line_registry(&e);
assert!(e.to_string().contains("--latest rolling resolution"));
}
}
#[cfg(test)]
mod exit_code_tests {
use super::*;
#[test]
fn manifest_and_config_problems_cannot_run() {
for e in [
Error::ManifestNotFound,
Error::ManifestValidation("bad".into()),
Error::SqlFileNotFound {
step: "s".into(),
path: PathBuf::from("missing.sql"),
},
Error::MissingParam { name: "p".into() },
Error::EngineNotFound {
engine: "duckdb".into(),
},
Error::EngineBinInvalid {
var: "ARC_DUCKDB_BIN",
path: PathBuf::from("/nope/duckdb"),
reason: "no such file".into(),
},
Error::UntestedEngine {
found: "3.0.0".into(),
range: crate::engine::SUPPORTED_ENGINE_RANGE,
override_var: "ARC_ALLOW_UNTESTED_ENGINE",
},
Error::EngineTooOld {
found: "1.5.6".into(),
oldest: crate::engine::OLDEST_ENGINE,
range: crate::engine::SUPPORTED_ENGINE_RANGE,
},
Error::StateBackend("locked".into()),
Error::ExtensionPinRefused {
refusals: vec!["mlpack".into()],
},
] {
assert_eq!(e.exit_code(), 1, "expected 'cannot run' (1) for {e:?}");
}
}
#[test]
fn execution_failures_found_a_problem() {
for e in [
Error::StepFailed {
step: "s".into(),
code: 1,
stderr: "boom".into(),
},
Error::Precondition {
step: "s".into(),
command: "test -f x".into(),
detail: "exit 1".into(),
},
Error::ToolPrecondition {
step: "s".into(),
tool: "duckdb".into(),
detail: "not found".into(),
},
Error::StepTimeout { step: "s".into() },
Error::PipelineTimeout {
step: "s".into(),
elapsed_sec: 3.0,
},
Error::ExtensionChanged {
changes: vec!["mlpack".into()],
},
Error::ExtensionRefusedBeforeItRan {
place: "step 's'".into(),
step: Some("s".into()),
refusals: vec!["anofox_forecast".into()],
},
Error::SqlNotRead {
place: "step 's'".into(),
step: Some("s".into()),
file: "models/s.sql".into(),
reason: "it printed nothing".into(),
},
Error::ExtensionPinRefusedBeforeItRan {
place: "hook on_success 'h'".into(),
step: None,
refusals: vec!["mlpack".into()],
},
] {
assert_eq!(e.exit_code(), 2, "expected 'found a problem' (2) for {e:?}");
}
}
#[test]
fn the_two_codes_are_actually_different() {
assert_ne!(
Error::ManifestNotFound.exit_code(),
Error::StepTimeout { step: "s".into() }.exit_code()
);
}
}