use std::path::{Component, Path, PathBuf};
#[cfg(feature = "serde")]
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
#[cfg_attr(feature = "serde", serde(rename_all = "kebab-case"))]
pub enum AgenticBackendKind {
AevSidecar,
AevStdio,
}
#[derive(Clone, Debug, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
#[cfg_attr(feature = "serde", serde(rename_all = "kebab-case"))]
pub enum ThinkingSetting {
None,
Low,
Medium,
High,
XHigh,
}
#[derive(Clone, Debug, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct AgenticSettings {
pub enabled: bool,
pub backend: AgenticBackendKind,
pub aev_bin: PathBuf,
pub workspace: Option<PathBuf>,
pub allow_file_writes: bool,
pub require_validation_after_writes: bool,
pub open_in_external_terminal: bool,
pub default_thinking: ThinkingSetting,
}
impl Default for AgenticSettings {
fn default() -> Self {
Self {
enabled: false,
backend: AgenticBackendKind::AevSidecar,
aev_bin: PathBuf::from("aev"),
workspace: None,
allow_file_writes: false,
require_validation_after_writes: true,
open_in_external_terminal: true,
default_thinking: ThinkingSetting::None,
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum WritePermission {
Disabled,
AllowedInsideWorkspace,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum SafetyViolation {
WritesDisabled,
EmptyTarget,
UrlTarget,
AbsoluteTarget,
ParentTraversal,
RuntimeStateTarget,
DirectoryTarget,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct SidecarCommand {
pub program: PathBuf,
pub args: Vec<String>,
}
impl SidecarCommand {
pub fn aev_scan(workspace: &Path, aev_bin: impl Into<PathBuf>) -> Self {
Self {
program: aev_bin.into(),
args: vec![
"--workspace".to_string(),
workspace.display().to_string(),
"--thinking".to_string(),
"none".to_string(),
"scan".to_string(),
],
}
}
pub fn aev_export(workspace: &Path, format: &str, aev_bin: impl Into<PathBuf>) -> Self {
Self {
program: aev_bin.into(),
args: vec![
"--workspace".to_string(),
workspace.display().to_string(),
"export".to_string(),
"--format".to_string(),
format.to_string(),
],
}
}
pub fn preview(&self) -> String {
let mut parts = vec![shell_word(&self.program.display().to_string())];
parts.extend(self.args.iter().map(|arg| shell_word(arg)));
parts.join(" ")
}
}
pub fn validate_write_target(
workspace: &Path,
target: &str,
permission: WritePermission,
) -> Result<PathBuf, SafetyViolation> {
if permission == WritePermission::Disabled {
return Err(SafetyViolation::WritesDisabled);
}
let trimmed = target.trim();
if trimmed.is_empty() {
return Err(SafetyViolation::EmptyTarget);
}
if trimmed.contains("://") {
return Err(SafetyViolation::UrlTarget);
}
let target_path = Path::new(trimmed);
if target_path.is_absolute() {
return Err(SafetyViolation::AbsoluteTarget);
}
for component in target_path.components() {
match component {
Component::ParentDir => return Err(SafetyViolation::ParentTraversal),
Component::Normal(value) if value == ".aev" || value == ".xrae" => {
return Err(SafetyViolation::RuntimeStateTarget)
}
_ => {}
}
}
if target_path.file_name().is_none() {
return Err(SafetyViolation::DirectoryTarget);
}
Ok(workspace.join(target_path))
}
fn shell_word(value: &str) -> String {
if value
.chars()
.all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-' | '.' | '/' | ':'))
{
value.to_string()
} else {
format!("'{}'", value.replace('\'', "'\\''"))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn settings_default_to_safe_disabled() {
let settings = AgenticSettings::default();
assert!(!settings.enabled);
assert!(!settings.allow_file_writes);
assert!(settings.require_validation_after_writes);
}
#[test]
fn write_guard_rejects_unsafe_targets() {
let workspace = Path::new("/tmp/work");
assert_eq!(
validate_write_target(
workspace,
"../secret",
WritePermission::AllowedInsideWorkspace
),
Err(SafetyViolation::ParentTraversal)
);
assert_eq!(
validate_write_target(
workspace,
".aev/session.json",
WritePermission::AllowedInsideWorkspace
),
Err(SafetyViolation::RuntimeStateTarget)
);
assert_eq!(
validate_write_target(
workspace,
"https://example.com/file",
WritePermission::AllowedInsideWorkspace
),
Err(SafetyViolation::UrlTarget)
);
}
#[test]
fn sidecar_preview_quotes_spaces() {
let command = SidecarCommand::aev_scan(Path::new("/tmp/my work"), "aev");
assert!(command.preview().contains("'/tmp/my work'"));
}
}