Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions cli/src/adapters/inbound/cli/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
//! CLI inbound adapter: parses command-line input and invokes use cases.

pub(crate) mod setup;
10 changes: 10 additions & 0 deletions cli/src/adapters/inbound/cli/setup.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
//! Inbound CLI rendering for the `setup` command's use-case reports.
use crate::application::use_cases::ensure_context_baseline::EnsureContextBaselineReport;
use crate::services::style::success;

/// Renders the result of `EnsureContextBaseline::execute` as the CLI's
/// existing "Context baseline ensured." success message.
pub(crate) fn render_context_baseline_report(_report: &EnsureContextBaselineReport) -> String {
success("Context baseline ensured.")
}
3 changes: 3 additions & 0 deletions cli/src/adapters/inbound/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
//! Inbound adapters: entrypoints that drive the application layer.
pub(crate) mod cli;
8 changes: 8 additions & 0 deletions cli/src/adapters/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
//! Adapters layer: inbound and outbound implementations of application ports.
//!
//! Adapters may depend on `crate::application` and, transitionally, on
//! `crate::services`. See `context/architecture.md` for the full
//! dependency-direction rules.
pub(crate) mod inbound;
pub(crate) mod outbound;
98 changes: 98 additions & 0 deletions cli/src/adapters/outbound/assets/embedded_integration_assets.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
//! `EmbeddedIntegrationAssetCatalog`: the `IntegrationAssetCatalog` outbound
//! adapter wrapping the existing generated embedded-asset catalog in
//! `services::setup`.

use std::borrow::Cow;
use std::convert::Infallible;

use crate::application::ports::integration_asset_catalog::IntegrationAssetCatalog;
use crate::domain::integration::{IntegrationAsset, IntegrationTarget};
use crate::services::setup;

fn setup_target_for(target: IntegrationTarget) -> setup::SetupTarget {
match target {
IntegrationTarget::OpenCode => setup::SetupTarget::OpenCode,
IntegrationTarget::Claude => setup::SetupTarget::Claude,
IntegrationTarget::Pi => setup::SetupTarget::Pi,
}
}

/// Resolves embedded assets for a concrete integration target by delegating
/// to `services::setup::iter_embedded_assets_for_setup_target_with_selection`.
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub(crate) struct EmbeddedIntegrationAssetCatalog;

impl IntegrationAssetCatalog for EmbeddedIntegrationAssetCatalog {
type Error = Infallible;

fn assets_for(
&self,
target: IntegrationTarget,
optional_workflows: &[String],
) -> Result<Vec<IntegrationAsset>, Self::Error> {
let assets = setup::iter_embedded_assets_for_setup_target_with_selection(
setup_target_for(target),
optional_workflows,
)
.map(|asset| IntegrationAsset {
relative_path: asset.relative_path.to_string(),
bytes: Cow::Borrowed(asset.bytes),
})
.collect();

Ok(assets)
}
}

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

#[test]
fn assets_for_matches_the_underlying_generated_catalog() {
let optional_workflows = vec!["research".to_string()];

let adapter_assets = EmbeddedIntegrationAssetCatalog
.assets_for(IntegrationTarget::Claude, &optional_workflows)
.expect("adapter catalog lookup");

let expected: Vec<IntegrationAsset> =
setup::iter_embedded_assets_for_setup_target_with_selection(
setup::SetupTarget::Claude,
&optional_workflows,
)
.map(|asset| IntegrationAsset {
relative_path: asset.relative_path.to_string(),
bytes: Cow::Borrowed(asset.bytes),
})
.collect();

assert_eq!(adapter_assets, expected);
}

#[test]
fn assets_for_maps_every_concrete_target() {
for target in [
IntegrationTarget::OpenCode,
IntegrationTarget::Claude,
IntegrationTarget::Pi,
] {
let adapter_assets = EmbeddedIntegrationAssetCatalog
.assets_for(target, &[])
.expect("adapter catalog lookup");

let expected: Vec<IntegrationAsset> =
setup::iter_embedded_assets_for_setup_target_with_selection(
setup_target_for(target),
&[] as &[String],
)
.map(|asset| IntegrationAsset {
relative_path: asset.relative_path.to_string(),
bytes: Cow::Borrowed(asset.bytes),
})
.collect();

assert_eq!(adapter_assets, expected);
}
}
}
3 changes: 3 additions & 0 deletions cli/src/adapters/outbound/assets/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
//! Outbound adapters over the generated embedded-asset catalogs.

pub(crate) mod embedded_integration_assets;
207 changes: 207 additions & 0 deletions cli/src/adapters/outbound/filesystem/context_store.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
//! `FilesystemContextStore`: the `ContextStore` outbound adapter that
//! persists the durable-context baseline to disk.

use std::fmt;
use std::fs;
use std::path::{Path, PathBuf};

use crate::application::ports::context_store::{ContextBaselineChanges, ContextStore};
use crate::domain::context::baseline::ContextBaseline;

/// A filesystem operation failed for a specific baseline path.
#[derive(Debug)]
pub(crate) struct ContextStoreError {
pub(crate) path: PathBuf,
pub(crate) source: std::io::Error,
}

impl fmt::Display for ContextStoreError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"failed to write context baseline path '{}': {}",
self.path.display(),
self.source
)
}
}

impl std::error::Error for ContextStoreError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
Some(&self.source)
}
}

/// Persists the durable-context baseline directly to the filesystem.
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub(crate) struct FilesystemContextStore;

impl ContextStore for FilesystemContextStore {
type Error = ContextStoreError;

fn ensure_baseline(
&self,
repository_root: &Path,
baseline: &ContextBaseline,
) -> Result<ContextBaselineChanges, Self::Error> {
let mut changes = ContextBaselineChanges::default();

for relative_directory in &baseline.directories {
let directory = repository_root.join(relative_directory);
if directory.exists() {
changes.existing_directories.push(directory);
} else {
create_dir_all(&directory)?;
changes.created_directories.push(directory);
}
}

for file in &baseline.files {
let path = repository_root.join(&file.relative_path);
if path.exists() {
changes.existing_files.push(path);
continue;
}

if let Some(parent) = path.parent() {
create_dir_all(parent)?;
}

fs::write(&path, file.initial_content).map_err(|source| ContextStoreError {
path: path.clone(),
source,
})?;
changes.created_files.push(path);
}

Ok(changes)
}
}

fn create_dir_all(path: &Path) -> Result<(), ContextStoreError> {
fs::create_dir_all(path).map_err(|source| ContextStoreError {
path: path.to_path_buf(),
source,
})
}

#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use std::time::{SystemTime, UNIX_EPOCH};

fn unique_temp_dir(label: &str) -> PathBuf {
let nonce = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("system time should be after Unix epoch")
.as_nanos();
let dir = std::env::temp_dir().join(format!(
"sce-filesystem-context-store-{label}-{}-{nonce}",
std::process::id()
));
fs::create_dir_all(&dir).expect("create temp dir");
dir
}

#[test]
fn ensure_baseline_creates_a_missing_baseline_fully() {
let repo = unique_temp_dir("full");
let baseline = ContextBaseline::sce_default();
let store = FilesystemContextStore;

let changes = store
.ensure_baseline(&repo, &baseline)
.expect("ensure baseline");

assert_eq!(
changes.created_directories.len(),
baseline.directories.len()
);
assert!(changes.existing_directories.is_empty());
assert_eq!(changes.created_files.len(), baseline.files.len());
assert!(changes.existing_files.is_empty());

for directory in &baseline.directories {
assert!(repo.join(directory).is_dir());
}
for file in &baseline.files {
let content = fs::read_to_string(repo.join(&file.relative_path)).expect("read file");
assert_eq!(content, file.initial_content);
}
}

#[test]
fn ensure_baseline_leaves_existing_custom_content_byte_for_byte_unchanged() {
let repo = unique_temp_dir("idempotent");
let baseline = ContextBaseline::sce_default();
let store = FilesystemContextStore;

store
.ensure_baseline(&repo, &baseline)
.expect("initial bootstrap");

let overview_path = repo.join("context/overview.md");
let sentinel = "SENTINEL_OVERVIEW_CONTENT\n";
fs::write(&overview_path, sentinel).expect("seed sentinel");

store
.ensure_baseline(&repo, &baseline)
.expect("rerun bootstrap");

let content = fs::read_to_string(&overview_path).expect("read overview");
assert_eq!(content, sentinel);
}

#[test]
fn ensure_baseline_second_run_reports_only_existing_paths() {
let repo = unique_temp_dir("second-run");
let baseline = ContextBaseline::sce_default();
let store = FilesystemContextStore;

store
.ensure_baseline(&repo, &baseline)
.expect("initial bootstrap");

let changes = store
.ensure_baseline(&repo, &baseline)
.expect("second bootstrap");

assert!(changes.created_directories.is_empty());
assert!(changes.created_files.is_empty());
assert_eq!(
changes.existing_directories.len(),
baseline.directories.len()
);
assert_eq!(changes.existing_files.len(), baseline.files.len());
}

#[test]
fn ensure_baseline_creates_only_missing_paths_in_a_partially_bootstrapped_tree() {
let repo = unique_temp_dir("partial");
let baseline = ContextBaseline::sce_default();
let store = FilesystemContextStore;

fs::create_dir_all(repo.join("context")).expect("seed context dir");
fs::write(repo.join("context/overview.md"), "existing\n").expect("seed overview file");

let changes = store
.ensure_baseline(&repo, &baseline)
.expect("partial bootstrap");

assert_eq!(changes.existing_directories, vec![repo.join("context")]);
assert_eq!(
changes.created_directories.len(),
baseline.directories.len() - 1
);
assert_eq!(
changes.existing_files,
vec![repo.join("context/overview.md")]
);
assert_eq!(changes.created_files.len(), baseline.files.len() - 1);

let overview_content =
fs::read_to_string(repo.join("context/overview.md")).expect("read overview");
assert_eq!(overview_content, "existing\n");
}
}
Loading
Loading