use serde::{Deserialize, Serialize};
use serde_json::Value;
use sha2::{Digest, Sha256};
use std::collections::{HashSet, VecDeque};
use std::fs;
use std::io::Write;
use std::path::PathBuf;
use std::sync::Mutex;
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum Level {
Off,
Suggest,
SafeApply,
Full,
}
impl Level {
pub fn parse(s: &str) -> Level {
match s.trim().to_lowercase().as_str() {
"off" => Level::Off,
"safe-apply" | "safe_apply" | "safeapply" => Level::SafeApply,
"full" => Level::Full,
_ => Level::Suggest,
}
}
pub fn load(path: &PathBuf) -> Level {
fs::read_to_string(path)
.map(|t| Level::parse(&t))
.unwrap_or(Level::Suggest)
}
pub fn save(&self, path: &PathBuf) -> std::io::Result<()> {
let s = match self {
Level::Off => "off",
Level::Suggest => "suggest",
Level::SafeApply => "safe-apply",
Level::Full => "full",
};
write_private(path, s.as_bytes())
}
}
#[derive(Debug, Clone)]
pub struct Proposal {
pub surface: String,
pub intent: String,
pub lines: usize,
pub bytes: usize,
pub fingerprint: String,
}
impl Proposal {
pub fn patch(file: impl Into<String>, old: &str, new: &str, intent: &str) -> Self {
let file = file.into();
let lines = new.matches('\n').count() + old.matches('\n').count() + 1;
let bytes = old.len() + new.len();
Self {
surface: file.clone(),
intent: intent.to_string(),
lines,
bytes,
fingerprint: sha256_hex(format!("{file}{old}{new}").as_bytes()),
}
}
pub fn action(
surface: impl Into<String>,
intent: impl Into<String>,
lines: usize,
bytes: usize,
fingerprint: impl Into<String>,
) -> Self {
Self {
surface: surface.into(),
intent: intent.into(),
lines,
bytes,
fingerprint: fingerprint.into(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "decision", rename_all = "kebab-case")]
pub enum Decision {
Permit,
Hold { reason: String },
Deny { reason: String },
}
pub struct Config {
pub protected_basenames: HashSet<String>,
pub protected_prefixes: Vec<String>,
pub max_lines: usize,
pub max_bytes: usize,
pub min_interval: Duration,
pub dedupe_capacity: usize,
pub journal_path: Option<PathBuf>,
}
impl Default for Config {
fn default() -> Self {
Self {
protected_basenames: HashSet::new(),
protected_prefixes: Vec::new(),
max_lines: 20,
max_bytes: 1_000,
min_interval: Duration::from_secs(0),
dedupe_capacity: 512,
journal_path: None,
}
}
}
struct State {
last_apply: Option<Instant>,
dedupe: VecDeque<String>,
dedupe_set: HashSet<String>,
}
pub struct Akicita {
level: Mutex<Level>,
level_path: Option<PathBuf>,
config: Config,
state: Mutex<State>,
}
impl Akicita {
pub fn new(level: Level, config: Config, level_path: Option<PathBuf>) -> Self {
Self {
level: Mutex::new(level),
level_path,
config,
state: Mutex::new(State {
last_apply: None,
dedupe: VecDeque::new(),
dedupe_set: HashSet::new(),
}),
}
}
pub fn level(&self) -> Level {
if let Some(path) = &self.level_path {
let live = Level::load(path);
if let Ok(mut l) = self.level.lock() {
*l = live;
}
return live;
}
*self.level.lock().expect("level lock")
}
pub fn set_level(&self, level: Level) {
*self.level.lock().expect("level lock") = level;
if let Some(path) = &self.level_path {
let _ = level.save(path);
}
}
fn is_protected(&self, surface: &str) -> bool {
if self
.config
.protected_prefixes
.iter()
.any(|p| surface.starts_with(p))
{
return true;
}
let basename = surface.rsplit('/').next().unwrap_or(surface);
self.config.protected_basenames.contains(basename)
}
pub fn check(&self, proposal: &Proposal) -> Decision {
let level = self.level();
let mut state = self.state.lock().expect("state lock");
let decision = self.decide(level, proposal, &state);
if matches!(decision, Decision::Permit) {
state.last_apply = Some(Instant::now());
}
state.dedupe_set.insert(proposal.fingerprint.clone());
state.dedupe.push_back(proposal.fingerprint.clone());
while state.dedupe.len() > self.config.dedupe_capacity {
if let Some(old) = state.dedupe.pop_front() {
state.dedupe_set.remove(&old);
}
}
self.journal(level, proposal, &decision);
decision
}
fn decide(&self, level: Level, p: &Proposal, state: &State) -> Decision {
if level == Level::Off {
return Decision::Deny {
reason: "autonomy level is off".into(),
};
}
if state.dedupe_set.contains(&p.fingerprint) {
return Decision::Deny {
reason: "proposal already decided — not refiling".into(),
};
}
if p.lines > self.config.max_lines || p.bytes > self.config.max_bytes {
return Decision::Hold {
reason: format!(
"oversize: {} lines/{} bytes exceeds {} line/{} byte cap — held for human",
p.lines, p.bytes, self.config.max_lines, self.config.max_bytes
),
};
}
if self.is_protected(&p.surface) {
return Decision::Hold {
reason: format!(
"protected surface {} — proposed and logged for human review, never auto-applied",
p.surface
),
};
}
match level {
Level::Suggest => Decision::Hold {
reason: "suggest level — recorded for review".into(),
},
Level::SafeApply | Level::Full => {
if let Some(last) = state.last_apply {
if last.elapsed() < self.config.min_interval {
return Decision::Deny {
reason: format!(
"rate limited: {:.0}s since last apply < {}s minimum",
last.elapsed().as_secs_f64(),
self.config.min_interval.as_secs()
),
};
}
}
Decision::Permit
}
Level::Off => unreachable!(),
}
}
fn journal(&self, level: Level, p: &Proposal, d: &Decision) {
let Some(path) = &self.config.journal_path else {
return;
};
let ts = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let line = serde_json::json!({
"ts": ts,
"level": serde_json::to_value(level).unwrap_or(Value::Null),
"surface": p.surface,
"intent": p.intent,
"lines": p.lines,
"bytes": p.bytes,
"fingerprint": p.fingerprint,
"decision": serde_json::to_value(d).unwrap_or(Value::Null),
});
if let Ok(mut f) = fs::OpenOptions::new().create(true).append(true).open(path) {
let _ = f.write_all(line.to_string().as_bytes());
let _ = f.write_all(b"\n");
}
}
}
fn sha256_hex(data: &[u8]) -> String {
hex::encode(Sha256::digest(data))
}
fn write_private(path: &PathBuf, bytes: &[u8]) -> std::io::Result<()> {
fs::write(path, bytes)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = fs::set_permissions(path, fs::Permissions::from_mode(0o600));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn marshal(level: Level) -> Akicita {
let mut config = Config::default();
config
.protected_basenames
.insert("Engine.swift".to_string());
Akicita::new(level, config, None)
}
#[test]
fn off_denies_everything() {
let a = marshal(Level::Off);
let p = Proposal::patch("/tmp/x.rs", "a", "b", "test");
assert!(matches!(a.check(&p), Decision::Deny { .. }));
}
#[test]
fn suggest_holds_everything() {
let a = marshal(Level::Suggest);
let p = Proposal::patch("/tmp/x.rs", "a", "b", "test");
assert!(matches!(a.check(&p), Decision::Hold { .. }));
}
#[test]
fn safe_apply_permits_small_unprotected() {
let a = marshal(Level::SafeApply);
let p = Proposal::patch("/tmp/x.rs", "a", "b", "test");
assert_eq!(a.check(&p), Decision::Permit);
}
#[test]
fn protected_never_auto_applies_even_full() {
let a = marshal(Level::Full);
let p = Proposal::patch("/project/Engine.swift", "a", "b", "test");
assert!(matches!(a.check(&p), Decision::Hold { .. }));
}
#[test]
fn oversize_holds_not_denies() {
let a = marshal(Level::SafeApply);
let big = "x".repeat(2_000);
let p = Proposal::patch("/tmp/x.rs", "", &big, "huge patch");
assert!(matches!(a.check(&p), Decision::Hold { .. }));
}
#[test]
fn dedupe_blocks_refiling() {
let a = marshal(Level::SafeApply);
let p = Proposal::patch("/tmp/x.rs", "a", "b", "test");
a.check(&p);
assert!(matches!(a.check(&p), Decision::Deny { .. }));
}
#[test]
fn rate_limit_enforced() {
let config = Config {
min_interval: Duration::from_secs(3600),
..Config::default()
};
let a = Akicita::new(Level::Full, config, None);
let p1 = Proposal::patch("/tmp/a.rs", "a", "b", "one");
let p2 = Proposal::patch("/tmp/b.rs", "c", "d", "two");
assert_eq!(a.check(&p1), Decision::Permit);
assert!(matches!(a.check(&p2), Decision::Deny { .. }));
}
#[test]
fn live_level_reload() {
let dir = std::env::temp_dir().join(format!("akicita-test-{}", std::process::id()));
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).unwrap();
let level_path = dir.join("autopilot_level");
fs::write(&level_path, "off").unwrap();
let a = Akicita::new(Level::Full, Config::default(), Some(level_path.clone()));
let p = Proposal::patch("/tmp/x.rs", "a", "b", "test");
assert!(matches!(a.check(&p), Decision::Deny { .. }));
fs::write(&level_path, "safe-apply").unwrap();
let p2 = Proposal::patch("/tmp/y.rs", "a", "b", "test2");
assert_eq!(a.check(&p2), Decision::Permit);
let _ = fs::remove_dir_all(&dir);
}
}