Skip to main content

atelier_sdk/
config.rs

1use std::env;
2use std::fmt;
3use std::fs;
4use std::path::{Path, PathBuf};
5
6use serde::{Deserialize, Serialize};
7
8use crate::error::{Error, config_err};
9
10/// The actor a workspace attributes its snapshots and journal entries to.
11#[derive(Debug, Clone, PartialEq, Eq)]
12pub struct Actor {
13    /// The actor's display name, as it appears in the journal.
14    pub name: String,
15    /// What kind of actor this is.
16    pub kind: ActorKind,
17}
18
19/// What kind of actor acted: a person, an AI agent, or an automation.
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
21#[serde(rename_all = "lowercase")]
22pub enum ActorKind {
23    /// A person.
24    Human,
25    /// An AI agent.
26    Agent,
27    /// An unattended process: a watcher, a bot, a pipeline.
28    Automation,
29}
30
31impl ActorKind {
32    /// The kind's canonical lowercase name, as stored and rendered.
33    #[must_use]
34    pub fn as_str(self) -> &'static str {
35        match self {
36            Self::Human => "human",
37            Self::Agent => "agent",
38            Self::Automation => "automation",
39        }
40    }
41}
42
43impl fmt::Display for ActorKind {
44    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
45        f.write_str(self.as_str())
46    }
47}
48
49impl std::str::FromStr for ActorKind {
50    type Err = Error;
51
52    fn from_str(text: &str) -> Result<Self, Self::Err> {
53        match text {
54            "human" => Ok(Self::Human),
55            "agent" => Ok(Self::Agent),
56            "automation" => Ok(Self::Automation),
57            other => Err(Error::Config(format!("unknown actor kind: {other}"))),
58        }
59    }
60}
61
62#[derive(Debug, Deserialize)]
63struct ActorFile {
64    actor: Option<ActorSection>,
65}
66
67#[derive(Debug, Deserialize)]
68struct ActorSection {
69    name: String,
70    kind: ActorKind,
71}
72
73/// Resolve the actor from the first config home that applies.
74///
75/// Order: `$ATELIER_CONFIG_HOME/config.toml`, else
76/// `$XDG_CONFIG_HOME/atelier/config.toml`, else
77/// `~/.config/atelier/config.toml`. A missing file or a file without an
78/// `[actor]` section yields [`Error::NoActorConfigured`].
79pub fn resolve_actor() -> Result<Actor, Error> {
80    let Some(path) = actor_config_path() else {
81        return Err(Error::NoActorConfigured);
82    };
83    if !path.is_file() {
84        return Err(Error::NoActorConfigured);
85    }
86    let text = fs::read_to_string(&path)?;
87    let parsed: ActorFile = toml::from_str(&text).map_err(config_err)?;
88    match parsed.actor {
89        Some(section) => Ok(Actor {
90            name: section.name,
91            kind: section.kind,
92        }),
93        None => Err(Error::NoActorConfigured),
94    }
95}
96
97fn actor_config_path() -> Option<PathBuf> {
98    if let Ok(home) = env::var("ATELIER_CONFIG_HOME") {
99        return Some(PathBuf::from(home).join("config.toml"));
100    }
101    if let Ok(xdg) = env::var("XDG_CONFIG_HOME") {
102        return Some(PathBuf::from(xdg).join("atelier").join("config.toml"));
103    }
104    if let Ok(home) = env::var("HOME") {
105        return Some(
106            PathBuf::from(home)
107                .join(".config")
108                .join("atelier")
109                .join("config.toml"),
110        );
111    }
112    None
113}
114
115/// An external origin the workspace is attached to, as held in memory and as
116/// persisted under `[[source]]` in `.atelier/config.toml`. The mount names
117/// the subdirectory whose engine carries the source's history; `/` is the
118/// v1 root import — content folded into source zero, no engine of its own.
119#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
120pub struct Source {
121    /// The kind of origin the source is.
122    pub kind: SourceKind,
123    /// The origin's location: a folder path, a git repository path, or a
124    /// bucket URL for remote sources.
125    pub path: PathBuf,
126    /// How content moves between the workspace and the source.
127    pub sync: SyncPolicy,
128    /// The subdirectory whose engine carries the source's history; `/`
129    /// marks a v1 root import.
130    pub mount: String,
131    /// The git branch adoption found checked out; every landing on this
132    /// source moves it, so plain `git push` carries the shared line.
133    /// Absent for folder sources and detached-HEAD adoptions.
134    #[serde(default, skip_serializing_if = "Option::is_none")]
135    pub branch: Option<String>,
136}
137
138/// The mount value of a v1 root import.
139pub(crate) const ROOT_MOUNT: &str = "/";
140
141/// The kind of origin a source is.
142#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
143#[serde(rename_all = "kebab-case")]
144pub enum SourceKind {
145    /// A plain folder; content mirrors both ways on landings.
146    LocalFolder,
147    /// A git repository; adoption preserves its history and landings move
148    /// its branch.
149    LocalGit,
150    /// A bucket prefix behind the remote adapter (ADR-0012): s3://, gs://,
151    /// az://, or file:// for tests. The source path holds the URL.
152    Remote,
153}
154
155impl fmt::Display for SourceKind {
156    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
157        match self {
158            Self::LocalFolder => f.write_str("local-folder"),
159            Self::LocalGit => f.write_str("local-git"),
160            Self::Remote => f.write_str("remote"),
161        }
162    }
163}
164
165/// How content moves between a workspace and its source.
166#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
167#[serde(rename_all = "kebab-case")]
168pub enum SyncPolicy {
169    /// Changes land back into the source and source changes fold in.
170    TwoWay,
171}
172
173impl fmt::Display for SyncPolicy {
174    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
175        match self {
176            Self::TwoWay => f.write_str("two-way"),
177        }
178    }
179}
180
181/// The on-disk `.atelier/config.toml` describing one workspace.
182#[derive(Debug, Serialize, Deserialize)]
183pub struct WorkspaceConfig {
184    pub schema: u32,
185    pub workspace: WorkspaceSection,
186    #[serde(default)]
187    pub landing: LandingPolicy,
188    #[serde(default)]
189    pub journal: JournalPolicy,
190    #[serde(default, rename = "source")]
191    pub sources: Vec<Source>,
192}
193
194#[derive(Debug, Serialize, Deserialize)]
195pub struct WorkspaceSection {
196    pub name: String,
197}
198
199/// The `[landing]` policy: what a landing request needs before its change
200/// lands (ADR-0007). The default profile takes one approval, allows
201/// self-approval, and dismisses approvals when the change gains a snapshot.
202#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
203#[serde(default)]
204pub struct LandingPolicy {
205    /// How many approvals a landing request needs.
206    pub approvals: u32,
207    /// Whether the requester may approve their own request.
208    pub allow_self_approve: bool,
209    /// Whether a new snapshot on the change dismisses standing approvals.
210    pub dismiss_approvals_on_new_snapshots: bool,
211}
212
213impl Default for LandingPolicy {
214    fn default() -> Self {
215        Self {
216            approvals: 1,
217            allow_self_approve: true,
218            dismiss_approvals_on_new_snapshots: true,
219        }
220    }
221}
222
223/// The `[journal]` policy: how much of a session's instruction the journal
224/// keeps (ADR-0004).
225#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
226#[serde(default)]
227pub struct JournalPolicy {
228    /// How much of a session's instruction the journal keeps.
229    pub instruction_fidelity: InstructionFidelity,
230}
231
232impl Default for JournalPolicy {
233    fn default() -> Self {
234        Self {
235            instruction_fidelity: InstructionFidelity::Summary,
236        }
237    }
238}
239
240/// What the journal records of an instruction: the summary plus run
241/// reference, or additionally the verbatim body (audit profiles).
242#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
243#[serde(rename_all = "lowercase")]
244pub enum InstructionFidelity {
245    /// The summary and run reference only.
246    Summary,
247    /// Additionally the instruction's verbatim body.
248    Verbatim,
249}
250
251impl WorkspaceConfig {
252    pub fn new(name: String) -> Self {
253        Self {
254            schema: 1,
255            workspace: WorkspaceSection { name },
256            landing: LandingPolicy::default(),
257            journal: JournalPolicy::default(),
258            sources: Vec::new(),
259        }
260    }
261}
262
263/// Read `.atelier/config.toml` from a workspace's control directory.
264pub fn read_workspace_config(atelier_dir: &Path) -> Result<WorkspaceConfig, Error> {
265    let text = fs::read_to_string(atelier_dir.join("config.toml"))?;
266    toml::from_str(&text).map_err(config_err)
267}
268
269/// Write `.atelier/config.toml` into a workspace's control directory.
270pub fn write_workspace_config(atelier_dir: &Path, config: &WorkspaceConfig) -> Result<(), Error> {
271    let text = toml::to_string(config).map_err(config_err)?;
272    fs::write(atelier_dir.join("config.toml"), text)?;
273    Ok(())
274}