use std::path::{Path, PathBuf};
use ito_common::fs::StdFs;
use ito_common::paths;
use ito_domain::backend::{BackendArchiveClient, BackendChangeReader, BackendSyncClient};
use ito_domain::changes::ChangeLifecycleFilter;
use ito_domain::discovery;
use ito_domain::errors::DomainError;
use crate::ArtifactBundle;
use crate::backend_sync::read_bundle_from_change_dir;
use crate::errors::{CoreError, CoreResult};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ImportLifecycle {
Active,
Archived,
}
#[derive(Debug, Clone)]
pub struct LocalImportChange {
pub change_id: String,
pub source_path: PathBuf,
pub lifecycle: ImportLifecycle,
pub bundle: ArtifactBundle,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ImportAction {
Imported,
Previewed,
Skipped(String),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ImportItemStatus {
Imported,
Previewed,
Skipped,
Failed,
}
#[derive(Debug, Clone)]
pub struct ImportItemResult {
pub change_id: String,
pub lifecycle: ImportLifecycle,
pub status: ImportItemStatus,
pub message: Option<String>,
}
#[derive(Debug, Clone)]
pub struct ImportSummary {
pub results: Vec<ImportItemResult>,
pub imported: usize,
pub previewed: usize,
pub skipped: usize,
pub failed: usize,
}
pub trait BackendImportSink {
fn preview_change(&self, change: &LocalImportChange) -> CoreResult<ImportAction>;
fn import_change(&self, change: &LocalImportChange) -> CoreResult<ImportAction>;
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum ImportPlan {
Skip(String),
Import {
push_bundle: bool,
mark_archived: bool,
},
}
pub struct RepositoryBackedImportSink<'a, R, S, A> {
change_reader: &'a R,
sync_client: &'a S,
archive_client: &'a A,
}
impl<'a, R, S, A> RepositoryBackedImportSink<'a, R, S, A> {
pub fn new(change_reader: &'a R, sync_client: &'a S, archive_client: &'a A) -> Self {
Self {
change_reader,
sync_client,
archive_client,
}
}
}
impl<R, S, A> BackendImportSink for RepositoryBackedImportSink<'_, R, S, A>
where
R: BackendChangeReader,
S: BackendSyncClient,
A: BackendArchiveClient,
{
fn preview_change(&self, change: &LocalImportChange) -> CoreResult<ImportAction> {
match evaluate_import_plan(self.change_reader, self.sync_client, change)? {
ImportPlan::Skip(message) => Ok(ImportAction::Skipped(message)),
ImportPlan::Import { .. } => Ok(ImportAction::Previewed),
}
}
fn import_change(&self, change: &LocalImportChange) -> CoreResult<ImportAction> {
match evaluate_import_plan(self.change_reader, self.sync_client, change)? {
ImportPlan::Import {
push_bundle,
mark_archived,
} => {
if push_bundle {
let mut bundle = change.bundle.clone();
bundle.revision.clear();
self.sync_client
.push(&change.change_id, &bundle)
.map_err(|err| CoreError::process(err.to_string()))?;
}
if mark_archived {
self.archive_client
.mark_archived(&change.change_id)
.map_err(|err| CoreError::process(err.to_string()))?;
}
Ok(ImportAction::Imported)
}
ImportPlan::Skip(message) => Ok(ImportAction::Skipped(message)),
}
}
}
pub fn import_local_changes(
sink: &dyn BackendImportSink,
ito_path: &Path,
) -> CoreResult<ImportSummary> {
import_local_changes_with_options(sink, ito_path, false)
}
pub fn import_local_changes_with_options(
sink: &dyn BackendImportSink,
ito_path: &Path,
dry_run: bool,
) -> CoreResult<ImportSummary> {
let changes = discover_local_import_changes(ito_path)?;
let mut results = Vec::with_capacity(changes.len());
let mut imported = 0;
let mut previewed = 0;
let mut skipped = 0;
let mut failed = 0;
for change in changes {
let outcome = if dry_run {
sink.preview_change(&change)
} else {
sink.import_change(&change)
};
match outcome {
Ok(ImportAction::Imported) => {
imported += 1;
results.push(ImportItemResult {
change_id: change.change_id,
lifecycle: change.lifecycle,
status: ImportItemStatus::Imported,
message: None,
});
}
Ok(ImportAction::Previewed) => {
previewed += 1;
results.push(ImportItemResult {
change_id: change.change_id,
lifecycle: change.lifecycle,
status: ImportItemStatus::Previewed,
message: None,
});
}
Ok(ImportAction::Skipped(message)) => {
skipped += 1;
results.push(ImportItemResult {
change_id: change.change_id,
lifecycle: change.lifecycle,
status: ImportItemStatus::Skipped,
message: Some(message),
});
}
Err(err) => {
failed += 1;
results.push(ImportItemResult {
change_id: change.change_id,
lifecycle: change.lifecycle,
status: ImportItemStatus::Failed,
message: Some(err.to_string()),
});
}
}
}
Ok(ImportSummary {
results,
imported,
previewed,
skipped,
failed,
})
}
fn evaluate_import_plan<R, S>(
change_reader: &R,
sync_client: &S,
change: &LocalImportChange,
) -> CoreResult<ImportPlan>
where
R: BackendChangeReader,
S: BackendSyncClient,
{
let active_exists = exists_in_backend(
change_reader,
&change.change_id,
ChangeLifecycleFilter::Active,
)?;
let archived_exists = exists_in_backend(
change_reader,
&change.change_id,
ChangeLifecycleFilter::Archived,
)?;
match change.lifecycle {
ImportLifecycle::Active => {
if archived_exists {
return Err(CoreError::validation(format!(
"backend already contains archived change '{}' while local copy is active",
change.change_id
)));
}
if !active_exists {
return Ok(ImportPlan::Import {
push_bundle: true,
mark_archived: false,
});
}
if remote_bundle_matches(sync_client, change)? {
return Ok(ImportPlan::Skip("already imported".to_string()));
}
Ok(ImportPlan::Import {
push_bundle: true,
mark_archived: false,
})
}
ImportLifecycle::Archived => {
if archived_exists {
return Ok(ImportPlan::Skip("already archived".to_string()));
}
if !active_exists {
return Ok(ImportPlan::Import {
push_bundle: true,
mark_archived: true,
});
}
let push_bundle = !remote_bundle_matches(sync_client, change)?;
Ok(ImportPlan::Import {
push_bundle,
mark_archived: true,
})
}
}
}
fn exists_in_backend<R>(
change_reader: &R,
change_id: &str,
filter: ChangeLifecycleFilter,
) -> CoreResult<bool>
where
R: BackendChangeReader,
{
match change_reader.get_change(change_id, filter) {
Ok(_) => Ok(true),
Err(DomainError::NotFound { .. }) => Ok(false),
Err(err) => Err(CoreError::from(err)),
}
}
fn remote_bundle_matches<S>(sync_client: &S, change: &LocalImportChange) -> CoreResult<bool>
where
S: BackendSyncClient,
{
let remote = sync_client
.pull(&change.change_id)
.map_err(|err| CoreError::process(err.to_string()))?;
Ok(artifact_bundles_match(&remote, &change.bundle))
}
fn artifact_bundles_match(left: &ArtifactBundle, right: &ArtifactBundle) -> bool {
left.change_id == right.change_id
&& left.proposal == right.proposal
&& left.design == right.design
&& left.tasks == right.tasks
&& left.specs == right.specs
}
fn discover_local_import_changes(ito_path: &Path) -> CoreResult<Vec<LocalImportChange>> {
let fs = StdFs;
let mut changes = Vec::new();
for change_id in discovery::list_change_dir_names(&fs, ito_path)? {
let path = paths::change_dir(ito_path, &change_id);
let bundle = read_bundle_from_change_dir(&path, &change_id)?;
changes.push(LocalImportChange {
change_id,
source_path: path,
lifecycle: ImportLifecycle::Active,
bundle,
});
}
let archive_dir = paths::changes_archive_dir(ito_path);
for archived_name in discovery::list_dir_names(&fs, &archive_dir)? {
let canonical_change_id = match canonical_archived_change_id(&archived_name) {
Ok(change_id) => change_id,
Err(err) => {
tracing::warn!(
archived_name = %archived_name,
error = %err,
"skipping unrecognized archived change directory during backend import"
);
continue;
}
};
let path = archive_dir.join(&archived_name);
let bundle = read_bundle_from_change_dir(&path, &canonical_change_id)?;
changes.push(LocalImportChange {
change_id: canonical_change_id,
source_path: path,
lifecycle: ImportLifecycle::Archived,
bundle,
});
}
changes.sort_by(|left, right| left.change_id.cmp(&right.change_id));
Ok(changes)
}
fn canonical_archived_change_id(archived_name: &str) -> CoreResult<String> {
let parts: Vec<&str> = archived_name.splitn(4, '-').collect();
if parts.len() != 4
|| parts[0].len() != 4
|| parts[1].len() != 2
|| parts[2].len() != 2
|| parts[3].is_empty()
|| !parts[0].chars().all(|ch| ch.is_ascii_digit())
|| !parts[1].chars().all(|ch| ch.is_ascii_digit())
|| !parts[2].chars().all(|ch| ch.is_ascii_digit())
{
return Err(CoreError::validation(format!(
"Archived change directory has unexpected format: {archived_name}"
)));
}
Ok(parts[3].to_string())
}