use std::collections::BTreeMap;
use std::fmt;
use std::path::PathBuf;
use std::time::Duration;
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::projection::ConceptProjection;
use crate::vendor::CliVendorKind;
macro_rules! id_newtype {
($name:ident, $prefix:literal) => {
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(transparent)]
pub struct $name(String);
impl $name {
pub fn new() -> Self {
Self(format!("{}-{}", $prefix, Uuid::new_v4()))
}
pub fn as_str(&self) -> &str {
&self.0
}
}
impl Default for $name {
fn default() -> Self {
Self::new()
}
}
impl fmt::Display for $name {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
impl From<String> for $name {
fn from(s: String) -> Self {
Self(s)
}
}
impl From<&str> for $name {
fn from(s: &str) -> Self {
Self(s.to_owned())
}
}
};
}
id_newtype!(CliRunId, "cli-run");
id_newtype!(CliSessionId, "cli-sess");
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum RunMode {
Headless,
Interactive,
}
impl Default for RunMode {
fn default() -> Self {
RunMode::Headless
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum IsolationSpec {
Local,
Docker {
image: String,
#[serde(default)]
mounts: Vec<DockerMount>,
#[serde(default)]
env: BTreeMap<String, String>,
#[serde(default)]
network: Option<String>,
},
}
impl Default for IsolationSpec {
fn default() -> Self {
IsolationSpec::Local
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DockerMount {
pub host_path: PathBuf,
pub container_path: PathBuf,
#[serde(default)]
pub read_only: bool,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct BudgetSpec {
#[serde(default, with = "duration_secs_opt", skip_serializing_if = "Option::is_none")]
pub wall_clock: Option<Duration>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_tokens: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_money_micro_usd: Option<u64>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CliRequest {
pub vendor: CliVendorKind,
#[serde(default)]
pub mode: RunMode,
#[serde(default)]
pub prompt: String,
pub workdir: PathBuf,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
#[serde(default)]
pub allowed_tools: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub resume_session: Option<String>,
#[serde(default)]
pub project: ConceptProjection,
#[serde(default)]
pub isolation: IsolationSpec,
#[serde(default)]
pub budget: BudgetSpec,
#[serde(default)]
pub metadata: BTreeMap<String, serde_json::Value>,
}
impl CliRequest {
pub fn new(vendor: CliVendorKind, workdir: impl Into<PathBuf>, prompt: impl Into<String>) -> Self {
Self {
vendor,
mode: RunMode::Headless,
prompt: prompt.into(),
workdir: workdir.into(),
model: None,
allowed_tools: Vec::new(),
resume_session: None,
project: ConceptProjection::default(),
isolation: IsolationSpec::Local,
budget: BudgetSpec::default(),
metadata: BTreeMap::new(),
}
}
pub fn with_mode(mut self, mode: RunMode) -> Self {
self.mode = mode;
self
}
pub fn with_model(mut self, model: impl Into<String>) -> Self {
self.model = Some(model.into());
self
}
pub fn with_isolation(mut self, isolation: IsolationSpec) -> Self {
self.isolation = isolation;
self
}
pub fn with_project(mut self, project: ConceptProjection) -> Self {
self.project = project;
self
}
}
mod duration_secs_opt {
use serde::{Deserialize, Deserializer, Serializer};
use std::time::Duration;
pub fn serialize<S: Serializer>(d: &Option<Duration>, s: S) -> Result<S::Ok, S::Error> {
match d {
Some(d) => s.serialize_some(&d.as_secs()),
None => s.serialize_none(),
}
}
pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<Option<Duration>, D::Error> {
let v: Option<u64> = Option::deserialize(d)?;
Ok(v.map(Duration::from_secs))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn request_round_trips_json() {
let r = CliRequest::new(CliVendorKind::Claude, "/tmp/workdir", "list files")
.with_model("claude-sonnet-4-6")
.with_mode(RunMode::Headless);
let j = serde_json::to_string(&r).unwrap();
let back: CliRequest = serde_json::from_str(&j).unwrap();
assert_eq!(back.vendor, CliVendorKind::Claude);
assert_eq!(back.model.as_deref(), Some("claude-sonnet-4-6"));
assert_eq!(back.mode, RunMode::Headless);
}
#[test]
fn isolation_default_is_local() {
assert!(matches!(IsolationSpec::default(), IsolationSpec::Local));
}
#[test]
fn run_id_is_unique() {
let a = CliRunId::new();
let b = CliRunId::new();
assert_ne!(a, b);
assert!(a.as_str().starts_with("cli-run-"));
}
}