use std::path::{Path, PathBuf};
use ito_config::types::{ItoConfig, RepositoryPersistenceMode};
use crate::errors::CoreError;
use crate::process::{ProcessRequest, ProcessRunner};
use crate::validate::{ValidationIssue, error, warning, with_metadata, with_rule_id};
use super::rule::{Rule, RuleContext, RuleId, RuleSeverity};
const SQLITE_DB_PATH_SET_ID: RuleId = RuleId::new("repository/sqlite-db-path-set");
const SQLITE_DB_NOT_COMMITTED_ID: RuleId = RuleId::new("repository/sqlite-db-not-committed");
fn mode_is_sqlite(config: &ItoConfig) -> bool {
match config.repository.mode {
RepositoryPersistenceMode::Sqlite => true,
RepositoryPersistenceMode::Filesystem => false,
}
}
fn resolve_db_path(config: &ItoConfig, project_root: &Path) -> Option<PathBuf> {
let raw = config.repository.sqlite.db_path.as_ref()?.trim();
if raw.is_empty() {
return None;
}
let path = PathBuf::from(raw);
if path.is_absolute() {
Some(path)
} else {
Some(project_root.join(path))
}
}
fn path_inside_root(path: &Path, root: &Path) -> bool {
let canonical_root = root.canonicalize().ok();
if let Some(canonical_root) = canonical_root.as_ref()
&& let Ok(canonical_path) = path.canonicalize()
{
return canonical_path.starts_with(canonical_root);
}
let normalized = lexical_normalize(path);
if let Some(canonical_root) = canonical_root.as_ref()
&& normalized.starts_with(canonical_root)
{
return true;
}
normalized.starts_with(root)
}
fn lexical_normalize(path: &Path) -> PathBuf {
use std::path::Component;
let mut out: Vec<Component<'_>> = Vec::new();
for component in path.components() {
match component {
Component::ParentDir => {
let pops_prior_normal = match out.last() {
Some(Component::Normal(_)) => true,
Some(_) | None => false,
};
if pops_prior_normal {
out.pop();
} else {
out.push(component);
}
}
Component::CurDir => {}
Component::Prefix(_) | Component::RootDir | Component::Normal(_) => {
out.push(component);
}
}
}
out.iter().collect()
}
pub(crate) struct SqliteDbPathSetRule;
impl Rule for SqliteDbPathSetRule {
fn id(&self) -> RuleId {
SQLITE_DB_PATH_SET_ID
}
fn severity(&self) -> RuleSeverity {
RuleSeverity::Error
}
fn description(&self) -> &'static str {
"SQLite db_path is set, resolves inside the project root, and has an existing parent."
}
fn gate(&self) -> Option<&'static str> {
Some("repository.mode == sqlite")
}
fn is_active(&self, config: &ItoConfig) -> bool {
mode_is_sqlite(config)
}
fn check(&self, ctx: &RuleContext<'_>) -> Result<Vec<ValidationIssue>, CoreError> {
let Some(db_path) = resolve_db_path(ctx.config, ctx.project_root) else {
let issue = error(
".ito/config.json",
"`repository.sqlite.db_path` is empty or unset while `repository.mode = \"sqlite\"`. \
The runtime cannot open a database without a path.",
);
let issue = with_rule_id(issue, SQLITE_DB_PATH_SET_ID.as_str());
let issue = with_metadata(
issue,
serde_json::json!({
"fix": "Set `repository.sqlite.db_path` to a project-relative path under \
`.ito/state/`, e.g. `.ito/state/ito.db`.",
"config_key": "repository.sqlite.db_path",
}),
);
return Ok(vec![issue]);
};
if !path_inside_root(&db_path, ctx.project_root) {
let issue = error(
".ito/config.json",
format!(
"`repository.sqlite.db_path = \"{path}\"` resolves outside the project root \
(`{root}`). The repository runtime confines persistence to the project tree.",
path = db_path.display(),
root = ctx.project_root.display(),
),
);
let issue = with_rule_id(issue, SQLITE_DB_PATH_SET_ID.as_str());
let issue = with_metadata(
issue,
serde_json::json!({
"fix": "Use a project-relative path (e.g. `.ito/state/ito.db`).",
"resolved": db_path.to_string_lossy(),
"project_root": ctx.project_root.to_string_lossy(),
}),
);
return Ok(vec![issue]);
}
let mut issues = Vec::new();
if let Some(parent) = db_path.parent()
&& !parent.exists()
{
let issue = warning(
".ito/config.json",
format!(
"`repository.sqlite.db_path` parent directory does not exist: `{parent}`. \
SQLite cannot create parent directories on its own; the database open \
will fail at runtime if the directory is still missing.",
parent = parent.display(),
),
);
let issue = with_rule_id(issue, SQLITE_DB_PATH_SET_ID.as_str());
issues.push(with_metadata(
issue,
serde_json::json!({
"fix": format!("Create the directory: `mkdir -p {}`", parent.display()),
"missing_parent": parent.to_string_lossy(),
}),
));
}
Ok(issues)
}
}
pub(crate) struct SqliteDbNotCommittedRule;
impl Rule for SqliteDbNotCommittedRule {
fn id(&self) -> RuleId {
SQLITE_DB_NOT_COMMITTED_ID
}
fn severity(&self) -> RuleSeverity {
RuleSeverity::Error
}
fn description(&self) -> &'static str {
"SQLite database file is not tracked by git and is covered by `.gitignore`."
}
fn gate(&self) -> Option<&'static str> {
Some("repository.mode == sqlite")
}
fn is_active(&self, config: &ItoConfig) -> bool {
mode_is_sqlite(config)
}
fn check(&self, ctx: &RuleContext<'_>) -> Result<Vec<ValidationIssue>, CoreError> {
let Some(db_path) = resolve_db_path(ctx.config, ctx.project_root) else {
return Ok(Vec::new());
};
let rel_str = match db_path.strip_prefix(ctx.project_root) {
Ok(rel) => rel.to_string_lossy().into_owned(),
Err(_) => db_path.to_string_lossy().into_owned(),
};
if git_tracks_path(ctx.runner, ctx.project_root, &rel_str)? {
let issue = error(
rel_str.clone(),
format!(
"SQLite database `{rel_str}` is currently tracked by git. \
Committing the live database leaks every entry through history \
and corrupts the file across machine clones.",
),
);
let issue = with_rule_id(issue, SQLITE_DB_NOT_COMMITTED_ID.as_str());
let issue = with_metadata(
issue,
serde_json::json!({
"fix": format!(
"Untrack: `git rm --cached {rel_str}`. \
Then ensure `.gitignore` covers it (e.g. `{rel_str}` or \
`{parent}/*.db`).",
parent = std::path::Path::new(&rel_str)
.parent()
.map(std::path::Path::to_string_lossy)
.unwrap_or_default(),
),
"untrack_command": format!("git rm --cached {rel_str}"),
"path": rel_str,
}),
);
return Ok(vec![issue]);
}
if !git_check_ignore(ctx.runner, ctx.project_root, &rel_str)? {
let issue = warning(
".gitignore",
format!(
"SQLite database `{rel_str}` is not currently tracked by git but is \
also not covered by `.gitignore`. A future `git add .` would \
accidentally commit it.",
),
);
let issue = with_rule_id(issue, SQLITE_DB_NOT_COMMITTED_ID.as_str());
return Ok(vec![with_metadata(
issue,
serde_json::json!({
"fix": format!("Append `{rel_str}` (or a glob like `*.db`) to `.gitignore`."),
"path": rel_str,
}),
)]);
}
Ok(Vec::new())
}
}
fn git_tracks_path(
runner: &dyn ProcessRunner,
project_root: &Path,
rel_path: &str,
) -> Result<bool, CoreError> {
let request = ProcessRequest::new("git")
.args(["ls-files", "--error-unmatch", "--", rel_path])
.current_dir(project_root);
let output = runner.run(&request).map_err(|err| {
CoreError::process(format!(
"Cannot run `git ls-files --error-unmatch {rel_path}`.\n\
Why: {err}\n\
Fix: ensure git is installed and `{root}` is a git repository.",
root = project_root.display(),
))
})?;
Ok(output.success)
}
fn git_check_ignore(
runner: &dyn ProcessRunner,
project_root: &Path,
rel_path: &str,
) -> Result<bool, CoreError> {
let request = ProcessRequest::new("git")
.args(["check-ignore", "-q", "--", rel_path])
.current_dir(project_root);
let output = runner.run(&request).map_err(|err| {
CoreError::process(format!(
"Cannot run `git check-ignore {rel_path}`.\n\
Why: {err}\n\
Fix: ensure git is installed and `{root}` is a git repository.",
root = project_root.display(),
))
})?;
Ok(output.success)
}
#[cfg(test)]
#[path = "repository_rules_tests.rs"]
mod repository_rules_tests;