roder-api 0.1.0

Agentic software development tools and SDKs for Roder.
Documentation
use serde::{Deserialize, Serialize};
use time::OffsetDateTime;

use crate::events::{ThreadId, TurnId};

#[derive(
    Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, Hash,
)]
#[serde(rename_all = "snake_case")]
pub enum PolicyMode {
    #[default]
    Default,
    #[serde(alias = "accept_edits", alias = "accept-edits")]
    AcceptAll,
    Plan,
    Bypass,
}

#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(transparent)]
pub struct AutoApproveSet {
    pub tools: Vec<String>,
}

impl AutoApproveSet {
    pub fn empty() -> Self {
        Self::default()
    }

    pub fn all() -> Self {
        Self {
            tools: vec!["*".to_string()],
        }
    }

    pub fn accept_all() -> Self {
        Self {
            tools: vec![
                "fs.write".to_string(),
                "fs.edit".to_string(),
                "fs.multi_edit".to_string(),
                "apply_patch".to_string(),
                "write_file".to_string(),
                "edit".to_string(),
                "multi_edit".to_string(),
                "process.spawn".to_string(),
                "shell".to_string(),
                "exec_command".to_string(),
                "write_stdin".to_string(),
                "vcs/select".to_string(),
                "vcs/snapshot/create".to_string(),
                "vcs/restore".to_string(),
                "vcs/lines/switch".to_string(),
                "vcs/sync".to_string(),
            ],
        }
    }

    pub fn contains_tool(&self, tool_name: &str) -> bool {
        self.tools
            .iter()
            .any(|tool| tool == "*" || tool == tool_name)
    }
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct PolicyModeConfig {
    pub auto_approve: AutoApproveSet,
    pub denied_tools: Vec<String>,
    pub allow_writes: bool,
    pub allow_process: bool,
    pub allow_network: bool,
    pub requires_user_to_exit: bool,
}

impl PolicyModeConfig {
    pub fn for_mode(mode: PolicyMode) -> Self {
        match mode {
            PolicyMode::Default => Self::default_mode(),
            PolicyMode::AcceptAll => Self::accept_all(),
            PolicyMode::Plan => Self::plan(),
            PolicyMode::Bypass => Self::bypass(),
        }
    }

    pub fn default_mode() -> Self {
        Self {
            auto_approve: AutoApproveSet::empty(),
            denied_tools: Vec::new(),
            allow_writes: true,
            allow_process: true,
            allow_network: true,
            requires_user_to_exit: false,
        }
    }

    pub fn accept_all() -> Self {
        Self {
            auto_approve: AutoApproveSet::accept_all(),
            denied_tools: Vec::new(),
            allow_writes: true,
            allow_process: true,
            allow_network: true,
            requires_user_to_exit: false,
        }
    }

    pub fn plan() -> Self {
        Self {
            auto_approve: AutoApproveSet::empty(),
            denied_tools: Vec::new(),
            allow_writes: false,
            allow_process: false,
            allow_network: true,
            requires_user_to_exit: false,
        }
    }

    pub fn bypass() -> Self {
        Self {
            auto_approve: AutoApproveSet::all(),
            denied_tools: Vec::new(),
            allow_writes: true,
            allow_process: true,
            allow_network: true,
            requires_user_to_exit: false,
        }
    }
}

impl Default for PolicyModeConfig {
    fn default() -> Self {
        Self::default_mode()
    }
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case", tag = "decision", content = "details")]
pub enum PolicyDecision {
    Allowed,
    RequiresApproval { reason: Option<String> },
    AutoApproved { matched_rule: Option<String> },
    Denied { reason: String },
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PolicyDecisionRecorded {
    pub thread_id: ThreadId,
    pub turn_id: TurnId,
    pub tool_id: String,
    pub tool_name: String,
    pub mode: PolicyMode,
    pub decision: PolicyDecision,
    #[serde(with = "time::serde::rfc3339")]
    pub timestamp: OffsetDateTime,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PolicyBypassActive {
    pub thread_id: ThreadId,
    pub turn_id: TurnId,
    pub tool_id: String,
    pub tool_name: String,
    #[serde(with = "time::serde::rfc3339")]
    pub timestamp: OffsetDateTime,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PolicyModeChanged {
    pub thread_id: ThreadId,
    pub turn_id: Option<TurnId>,
    pub previous_mode: PolicyMode,
    pub new_mode: PolicyMode,
    pub reason: Option<String>,
    #[serde(with = "time::serde::rfc3339")]
    pub timestamp: OffsetDateTime,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PolicyExitPlanRequested {
    pub thread_id: ThreadId,
    pub turn_id: TurnId,
    pub request_id: String,
    pub target_mode: PolicyMode,
    pub plan_summary: Option<String>,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub next_steps: Vec<String>,
    #[serde(with = "time::serde::rfc3339")]
    pub timestamp: OffsetDateTime,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PolicyExitPlanResolved {
    pub thread_id: ThreadId,
    pub turn_id: TurnId,
    pub request_id: String,
    pub approved: bool,
    pub target_mode: PolicyMode,
    pub resolved_mode: PolicyMode,
    #[serde(with = "time::serde::rfc3339")]
    pub timestamp: OffsetDateTime,
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn policy_mode_serde_round_trips_as_snake_case() {
        let serialized = serde_json::to_string(&PolicyMode::AcceptAll).unwrap();
        assert_eq!(serialized, "\"accept_all\"");

        let round_trip: PolicyMode = serde_json::from_str(&serialized).unwrap();
        assert_eq!(round_trip, PolicyMode::AcceptAll);

        let legacy: PolicyMode = serde_json::from_str("\"accept_edits\"").unwrap();
        assert_eq!(legacy, PolicyMode::AcceptAll);
    }

    #[test]
    fn policy_mode_config_serde_round_trips() {
        let config = PolicyModeConfig {
            auto_approve: AutoApproveSet {
                tools: vec!["fs.write".to_string(), "network".to_string()],
            },
            denied_tools: vec!["process.spawn".to_string()],
            allow_writes: true,
            allow_process: false,
            allow_network: true,
            requires_user_to_exit: false,
        };

        let serialized = serde_json::to_string(&config).unwrap();
        let round_trip: PolicyModeConfig = serde_json::from_str(&serialized).unwrap();

        assert_eq!(round_trip, config);
        assert!(round_trip.auto_approve.contains_tool("fs.write"));
        assert!(!round_trip.auto_approve.contains_tool("fs.read"));
    }

    #[test]
    fn policy_decision_serde_round_trips() {
        let decision = PolicyDecision::RequiresApproval {
            reason: Some("write access".to_string()),
        };

        let serialized = serde_json::to_string(&decision).unwrap();
        let round_trip: PolicyDecision = serde_json::from_str(&serialized).unwrap();

        assert_eq!(round_trip, decision);
    }

    #[test]
    fn policy_event_payloads_round_trip() {
        let event = PolicyExitPlanRequested {
            thread_id: "thread-a".to_string(),
            turn_id: "turn-a".to_string(),
            request_id: "exit-1".to_string(),
            target_mode: PolicyMode::Default,
            plan_summary: Some("Implement the approved edits.".to_string()),
            next_steps: vec!["edit files".to_string(), "run tests".to_string()],
            timestamp: OffsetDateTime::UNIX_EPOCH,
        };

        let serialized = serde_json::to_string(&event).unwrap();
        let round_trip: PolicyExitPlanRequested = serde_json::from_str(&serialized).unwrap();

        assert_eq!(round_trip.request_id, "exit-1");
        assert_eq!(round_trip.target_mode, PolicyMode::Default);
        assert_eq!(
            round_trip.plan_summary.as_deref(),
            Some("Implement the approved edits.")
        );
        assert_eq!(round_trip.next_steps, ["edit files", "run tests"]);
    }

    #[test]
    fn policy_mode_default_presets_match_contract() {
        let default = PolicyModeConfig::for_mode(PolicyMode::Default);
        assert_eq!(default.auto_approve, AutoApproveSet::empty());
        assert!(default.allow_writes);
        assert!(default.allow_process);
        assert!(default.allow_network);
        assert!(!default.requires_user_to_exit);

        let accept_all = PolicyModeConfig::for_mode(PolicyMode::AcceptAll);
        assert!(accept_all.auto_approve.contains_tool("fs.write"));
        assert!(accept_all.auto_approve.contains_tool("fs.edit"));
        assert!(accept_all.auto_approve.contains_tool("fs.multi_edit"));
        assert!(accept_all.auto_approve.contains_tool("apply_patch"));
        assert!(accept_all.auto_approve.contains_tool("write_file"));
        assert!(accept_all.auto_approve.contains_tool("edit"));
        assert!(accept_all.auto_approve.contains_tool("multi_edit"));
        assert!(accept_all.auto_approve.contains_tool("process.spawn"));
        assert!(accept_all.auto_approve.contains_tool("shell"));
        assert!(accept_all.auto_approve.contains_tool("exec_command"));
        assert!(accept_all.auto_approve.contains_tool("write_stdin"));
        assert!(accept_all.allow_writes);
        assert!(accept_all.allow_process);
        assert!(accept_all.allow_network);

        let plan = PolicyModeConfig::for_mode(PolicyMode::Plan);
        assert_eq!(plan.auto_approve, AutoApproveSet::empty());
        assert!(!plan.allow_writes);
        assert!(!plan.allow_process);
        assert!(plan.allow_network);
        assert!(!plan.requires_user_to_exit);

        let bypass = PolicyModeConfig::for_mode(PolicyMode::Bypass);
        assert!(bypass.auto_approve.contains_tool("any.tool"));
        assert!(bypass.allow_writes);
        assert!(bypass.allow_process);
        assert!(bypass.allow_network);
        assert!(!bypass.requires_user_to_exit);
    }
}