Skip to main content

ito_core/
artifact_mutations.rs

1//! Active change artifact mutation services.
2
3use std::path::{Path, PathBuf};
4
5#[cfg(feature = "backend")]
6use chrono::Utc;
7use diffy::{Patch, apply};
8
9#[cfg(feature = "backend")]
10use crate::backend_sync;
11use crate::errors::CoreError;
12use crate::repository_runtime::SqliteRuntime;
13use ito_common::id::parse_change_id;
14use ito_common::paths;
15#[cfg(feature = "backend")]
16use ito_domain::backend::BackendSyncClient;
17use ito_domain::backend::{ArtifactBundle, BackendError, BackendProjectStore};
18use ito_domain::changes::{
19    ChangeArtifactKind, ChangeArtifactMutationError, ChangeArtifactMutationResult,
20    ChangeArtifactMutationService, ChangeArtifactMutationServiceResult, ChangeArtifactRef,
21};
22
23use crate::change_repository::FsChangeRepository;
24use crate::sqlite_project_store::SqliteBackendProjectStore;
25
26/// Filesystem-backed change artifact mutation service.
27#[derive(Debug, Clone)]
28pub struct FsChangeArtifactMutationService {
29    ito_path: PathBuf,
30}
31
32impl FsChangeArtifactMutationService {
33    /// Create a filesystem-backed change artifact mutation service.
34    pub fn new(ito_path: impl Into<PathBuf>) -> Self {
35        Self {
36            ito_path: ito_path.into(),
37        }
38    }
39}
40
41impl ChangeArtifactMutationService for FsChangeArtifactMutationService {
42    fn load_artifact(
43        &self,
44        target: &ChangeArtifactRef,
45    ) -> ChangeArtifactMutationServiceResult<Option<String>> {
46        let path = artifact_path(&self.ito_path, target)?;
47        if !path.is_file() {
48            return Ok(None);
49        }
50        let contents = ito_common::io::read_to_string_std(&path)
51            .map_err(|err| ChangeArtifactMutationError::io("reading change artifact", err))?;
52        Ok(Some(contents))
53    }
54
55    fn write_artifact(
56        &self,
57        target: &ChangeArtifactRef,
58        content: &str,
59    ) -> ChangeArtifactMutationServiceResult<ChangeArtifactMutationResult> {
60        ensure_change_exists(&self.ito_path, &target.change_id)?;
61        let path = artifact_path(&self.ito_path, target)?;
62        let existed = path.is_file();
63        if let Some(parent) = path.parent() {
64            std::fs::create_dir_all(parent).map_err(|err| {
65                ChangeArtifactMutationError::io("creating parent directories for artifact", err)
66            })?;
67        }
68        ito_common::io::write_std(&path, content)
69            .map_err(|err| ChangeArtifactMutationError::io("writing change artifact", err))?;
70        Ok(ChangeArtifactMutationResult {
71            target: target.clone(),
72            existed,
73            revision: None,
74        })
75    }
76
77    fn patch_artifact(
78        &self,
79        target: &ChangeArtifactRef,
80        patch: &str,
81    ) -> ChangeArtifactMutationServiceResult<ChangeArtifactMutationResult> {
82        ensure_change_exists(&self.ito_path, &target.change_id)?;
83        let path = artifact_path(&self.ito_path, target)?;
84        let Some(current) = self.load_artifact(target)? else {
85            return Err(ChangeArtifactMutationError::not_found(format!(
86                "Artifact '{}' not found for patching",
87                target.label()
88            )));
89        };
90        let updated = apply_unified_patch(&current, patch, target)?;
91        if let Some(parent) = path.parent() {
92            std::fs::create_dir_all(parent).map_err(|err| {
93                ChangeArtifactMutationError::io("creating parent directories for artifact", err)
94            })?;
95        }
96        ito_common::io::write_std(&path, &updated)
97            .map_err(|err| ChangeArtifactMutationError::io("writing patched artifact", err))?;
98        Ok(ChangeArtifactMutationResult {
99            target: target.clone(),
100            existed: true,
101            revision: None,
102        })
103    }
104}
105
106/// Generic bundle-backed change artifact mutation service.
107#[derive(Debug, Clone)]
108pub struct BundleBackedChangeArtifactMutationService<C> {
109    client: C,
110}
111
112impl<C> BundleBackedChangeArtifactMutationService<C> {
113    /// Create a bundle-backed mutation service.
114    pub fn new(client: C) -> Self {
115        Self { client }
116    }
117}
118
119impl<C> ChangeArtifactMutationService for BundleBackedChangeArtifactMutationService<C>
120where
121    C: ChangeArtifactBundleClient + Send + Sync,
122{
123    fn load_artifact(
124        &self,
125        target: &ChangeArtifactRef,
126    ) -> ChangeArtifactMutationServiceResult<Option<String>> {
127        let bundle = self.client.pull_bundle(&target.change_id)?;
128        Ok(bundle_artifact_content(&bundle, &target.artifact))
129    }
130
131    fn write_artifact(
132        &self,
133        target: &ChangeArtifactRef,
134        content: &str,
135    ) -> ChangeArtifactMutationServiceResult<ChangeArtifactMutationResult> {
136        let mut bundle = self.client.pull_bundle(&target.change_id)?;
137        let existed = bundle_artifact_content(&bundle, &target.artifact).is_some();
138        set_bundle_artifact(&mut bundle, &target.artifact, content.to_string());
139        let revision = self.client.push_bundle(&target.change_id, &bundle)?;
140        Ok(ChangeArtifactMutationResult {
141            target: target.clone(),
142            existed,
143            revision: Some(revision),
144        })
145    }
146
147    fn patch_artifact(
148        &self,
149        target: &ChangeArtifactRef,
150        patch: &str,
151    ) -> ChangeArtifactMutationServiceResult<ChangeArtifactMutationResult> {
152        let mut bundle = self.client.pull_bundle(&target.change_id)?;
153        let Some(current) = bundle_artifact_content(&bundle, &target.artifact) else {
154            return Err(ChangeArtifactMutationError::not_found(format!(
155                "Artifact '{}' not found for patching",
156                target.label()
157            )));
158        };
159        let updated = apply_unified_patch(&current, patch, target)?;
160        set_bundle_artifact(&mut bundle, &target.artifact, updated);
161        let revision = self.client.push_bundle(&target.change_id, &bundle)?;
162        Ok(ChangeArtifactMutationResult {
163            target: target.clone(),
164            existed: true,
165            revision: Some(revision),
166        })
167    }
168}
169
170/// Client abstraction for bundle-backed artifact mutation services.
171pub trait ChangeArtifactBundleClient: std::fmt::Debug + Clone {
172    /// Pull the latest artifact bundle for a change.
173    fn pull_bundle(&self, change_id: &str) -> ChangeArtifactMutationServiceResult<ArtifactBundle>;
174
175    /// Push an updated artifact bundle and return the resulting revision.
176    fn push_bundle(
177        &self,
178        change_id: &str,
179        bundle: &ArtifactBundle,
180    ) -> ChangeArtifactMutationServiceResult<String>;
181}
182
183/// Local SQLite-backed bundle client.
184#[derive(Debug, Clone)]
185pub struct SqliteChangeArtifactBundleClient {
186    db_path: PathBuf,
187    org: String,
188    repo: String,
189}
190
191impl SqliteChangeArtifactBundleClient {
192    /// Create a SQLite-backed bundle client from resolved runtime settings.
193    pub fn new(runtime: &SqliteRuntime) -> Self {
194        Self {
195            db_path: runtime.db_path.clone(),
196            org: runtime.org.clone(),
197            repo: runtime.repo.clone(),
198        }
199    }
200
201    fn open_store(&self) -> ChangeArtifactMutationServiceResult<SqliteBackendProjectStore> {
202        SqliteBackendProjectStore::open(&self.db_path).map_err(change_artifact_error_from_core)
203    }
204}
205
206impl ChangeArtifactBundleClient for SqliteChangeArtifactBundleClient {
207    fn pull_bundle(&self, change_id: &str) -> ChangeArtifactMutationServiceResult<ArtifactBundle> {
208        let store = self.open_store()?;
209        store
210            .pull_artifact_bundle(&self.org, &self.repo, change_id)
211            .map_err(change_artifact_error_from_backend)
212    }
213
214    fn push_bundle(
215        &self,
216        change_id: &str,
217        bundle: &ArtifactBundle,
218    ) -> ChangeArtifactMutationServiceResult<String> {
219        let store = self.open_store()?;
220        let result = store
221            .push_artifact_bundle(&self.org, &self.repo, change_id, bundle)
222            .map_err(change_artifact_error_from_backend)?;
223        Ok(result.new_revision)
224    }
225}
226
227/// Filesystem-backed bundle client for active-change bundles.
228#[cfg(feature = "backend")]
229#[derive(Debug, Clone)]
230pub struct FsChangeArtifactBundleClient {
231    ito_path: PathBuf,
232}
233
234#[cfg(feature = "backend")]
235impl FsChangeArtifactBundleClient {
236    /// Create a filesystem-backed bundle client.
237    pub fn new(ito_path: impl Into<PathBuf>) -> Self {
238        Self {
239            ito_path: ito_path.into(),
240        }
241    }
242}
243
244#[cfg(feature = "backend")]
245impl ChangeArtifactBundleClient for FsChangeArtifactBundleClient {
246    fn pull_bundle(&self, change_id: &str) -> ChangeArtifactMutationServiceResult<ArtifactBundle> {
247        backend_sync::read_local_bundle(&self.ito_path, change_id)
248            .map_err(change_artifact_error_from_core)
249    }
250
251    fn push_bundle(
252        &self,
253        change_id: &str,
254        bundle: &ArtifactBundle,
255    ) -> ChangeArtifactMutationServiceResult<String> {
256        let revision = Utc::now().to_rfc3339();
257        let mut next = bundle.clone();
258        next.change_id = change_id.to_string();
259        next.revision = revision.clone();
260        backend_sync::write_bundle_to_local(&self.ito_path, change_id, &next)
261            .map_err(change_artifact_error_from_core)?;
262        Ok(revision)
263    }
264}
265
266/// Remote/backend-backed bundle client.
267#[cfg(feature = "backend")]
268#[derive(Debug, Clone)]
269pub struct RemoteChangeArtifactBundleClient<S> {
270    client: S,
271}
272
273#[cfg(feature = "backend")]
274impl<S> RemoteChangeArtifactBundleClient<S> {
275    /// Create a remote bundle client from an existing sync client.
276    pub fn new(client: S) -> Self {
277        Self { client }
278    }
279}
280
281#[cfg(feature = "backend")]
282impl<S> ChangeArtifactBundleClient for RemoteChangeArtifactBundleClient<S>
283where
284    S: BackendSyncClient + Clone + std::fmt::Debug,
285{
286    fn pull_bundle(&self, change_id: &str) -> ChangeArtifactMutationServiceResult<ArtifactBundle> {
287        self.client
288            .pull(change_id)
289            .map_err(change_artifact_error_from_backend)
290    }
291
292    fn push_bundle(
293        &self,
294        change_id: &str,
295        bundle: &ArtifactBundle,
296    ) -> ChangeArtifactMutationServiceResult<String> {
297        let result = self
298            .client
299            .push(change_id, bundle)
300            .map_err(change_artifact_error_from_backend)?;
301        Ok(result.new_revision)
302    }
303}
304
305fn ensure_change_exists(
306    ito_path: &Path,
307    change_id: &str,
308) -> ChangeArtifactMutationServiceResult<()> {
309    if parse_change_id(change_id).is_err() {
310        return Err(ChangeArtifactMutationError::validation(format!(
311            "Invalid change id '{change_id}'"
312        )));
313    }
314    if !FsChangeRepository::new(ito_path).exists(change_id) {
315        return Err(ChangeArtifactMutationError::not_found(format!(
316            "Change '{change_id}' not found"
317        )));
318    }
319    Ok(())
320}
321
322fn artifact_path(
323    ito_path: &Path,
324    target: &ChangeArtifactRef,
325) -> ChangeArtifactMutationServiceResult<PathBuf> {
326    if parse_change_id(&target.change_id).is_err() {
327        return Err(ChangeArtifactMutationError::validation(format!(
328            "Invalid change id '{}'",
329            target.change_id
330        )));
331    }
332
333    let base = paths::change_dir(ito_path, &target.change_id);
334    match &target.artifact {
335        ChangeArtifactKind::Proposal => Ok(base.join("proposal.md")),
336        ChangeArtifactKind::Design => Ok(base.join("design.md")),
337        ChangeArtifactKind::Tasks => crate::tasks::tracking_file_path(ito_path, &target.change_id)
338            .map_err(change_artifact_error_from_core),
339        ChangeArtifactKind::SpecDelta { capability } => {
340            validate_path_component(capability, "capability")?;
341            Ok(paths::change_specs_dir(ito_path, &target.change_id)
342                .join(capability)
343                .join("spec.md"))
344        }
345    }
346}
347
348fn validate_path_component(value: &str, label: &str) -> ChangeArtifactMutationServiceResult<()> {
349    if value.trim().is_empty() {
350        return Err(ChangeArtifactMutationError::validation(format!(
351            "{label} must not be empty"
352        )));
353    }
354    if value.contains("..") || value.contains('/') || value.contains('\\') || value.contains('\0') {
355        return Err(ChangeArtifactMutationError::validation(format!(
356            "{label} contains unsafe path characters: {value:?}"
357        )));
358    }
359    Ok(())
360}
361
362fn apply_unified_patch(
363    current: &str,
364    patch_text: &str,
365    target: &ChangeArtifactRef,
366) -> ChangeArtifactMutationServiceResult<String> {
367    let patch = Patch::from_str(patch_text).map_err(|err| {
368        ChangeArtifactMutationError::validation(format!(
369            "Invalid patch for '{}': {err}",
370            target.label()
371        ))
372    })?;
373    apply(current, &patch).map_err(|err| {
374        ChangeArtifactMutationError::validation(format!(
375            "Patch did not apply cleanly for '{}': {err}",
376            target.label()
377        ))
378    })
379}
380
381fn bundle_artifact_content(
382    bundle: &ArtifactBundle,
383    artifact: &ChangeArtifactKind,
384) -> Option<String> {
385    match artifact {
386        ChangeArtifactKind::Proposal => bundle.proposal.clone(),
387        ChangeArtifactKind::Design => bundle.design.clone(),
388        ChangeArtifactKind::Tasks => bundle.tasks.clone(),
389        ChangeArtifactKind::SpecDelta { capability } => bundle
390            .specs
391            .iter()
392            .find(|(name, _)| name == capability)
393            .map(|(_, content)| content.clone()),
394    }
395}
396
397fn set_bundle_artifact(
398    bundle: &mut ArtifactBundle,
399    artifact: &ChangeArtifactKind,
400    content: String,
401) {
402    match artifact {
403        ChangeArtifactKind::Proposal => bundle.proposal = Some(content),
404        ChangeArtifactKind::Design => bundle.design = Some(content),
405        ChangeArtifactKind::Tasks => bundle.tasks = Some(content),
406        ChangeArtifactKind::SpecDelta { capability } => {
407            if let Some((_, existing)) =
408                bundle.specs.iter_mut().find(|(name, _)| name == capability)
409            {
410                *existing = content;
411            } else {
412                bundle.specs.push((capability.clone(), content));
413                bundle.specs.sort_by(|left, right| left.0.cmp(&right.0));
414            }
415        }
416    }
417}
418
419fn change_artifact_error_from_core(err: CoreError) -> ChangeArtifactMutationError {
420    match err {
421        CoreError::Domain(domain) => ChangeArtifactMutationError::other(domain.to_string()),
422        CoreError::Io { context, source } => ChangeArtifactMutationError::io(context, source),
423        CoreError::Validation(message) => ChangeArtifactMutationError::validation(message),
424        CoreError::Parse(message) => ChangeArtifactMutationError::validation(message),
425        CoreError::Process(message) => ChangeArtifactMutationError::other(message),
426        CoreError::Sqlite(message) => {
427            ChangeArtifactMutationError::other(format!("sqlite error: {message}"))
428        }
429        CoreError::NotFound(message) => ChangeArtifactMutationError::not_found(message),
430        CoreError::Serde { context, message } => {
431            ChangeArtifactMutationError::other(format!("{context}: {message}"))
432        }
433        error @ CoreError::FeatureUnavailable { .. } => {
434            ChangeArtifactMutationError::other(error.to_string())
435        }
436    }
437}
438
439fn change_artifact_error_from_backend(err: BackendError) -> ChangeArtifactMutationError {
440    match err {
441        BackendError::LeaseConflict(conflict) => ChangeArtifactMutationError::validation(format!(
442            "Lease conflict while mutating '{}': change is claimed by '{}'",
443            conflict.change_id, conflict.holder
444        )),
445        BackendError::RevisionConflict(conflict) => {
446            ChangeArtifactMutationError::validation(format!(
447                "Revision conflict for '{}': local revision '{}' is stale (server has '{}'). Retry after re-reading the current artifact state.",
448                conflict.change_id, conflict.local_revision, conflict.server_revision
449            ))
450        }
451        BackendError::Unavailable(message) => {
452            ChangeArtifactMutationError::other(format!("backend unavailable: {message}"))
453        }
454        BackendError::Unauthorized(message) => {
455            ChangeArtifactMutationError::validation(format!("backend auth failed: {message}"))
456        }
457        BackendError::NotFound(message) => {
458            ChangeArtifactMutationError::not_found(format!("backend resource not found: {message}"))
459        }
460        BackendError::Other(message) => ChangeArtifactMutationError::other(message),
461    }
462}
463
464#[cfg(test)]
465#[path = "artifact_mutations_tests.rs"]
466mod artifact_mutations_tests;