Skip to main content

ito_core/
artifact_mutations.rs

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