use std::collections::BTreeMap;
use std::sync::Arc;
use async_trait::async_trait;
use serde::Serialize;
use serde_json::{Value, json};
use super::plan::{
ARCHIVE_PREFIX, Checked, FileKind, PipelineSource, Plan, PlanInputs, Status, archive_path, plan_sources,
};
use super::report;
use crate::platform::error::PlatformError;
use crate::platform::services::PlatformService;
pub const JOURNAL_FILE: &str = "archive/0.10/migration.json";
pub const REPORT_FILE: &str = "archive/0.10/MIGRATION.md";
#[async_trait]
pub trait PipelineSwitch: Send + Sync {
async fn activate(&self, owner: &str, project: &str, file_rel_path: &str) -> Result<(), PlatformError>;
async fn deactivate(&self, owner: &str, project: &str, file_rel_path: &str) -> Result<(), PlatformError>;
}
#[derive(Debug, Clone, Serialize)]
pub struct ApplyReport {
pub fingerprint: String,
pub ok: bool,
pub done: Vec<String>,
pub failed: Vec<String>,
}
pub struct MigrationService {
platform: Arc<PlatformService>,
}
impl MigrationService {
pub fn new(platform: Arc<PlatformService>) -> Self {
Self { platform }
}
pub(super) fn platform(&self) -> &PlatformService {
&self.platform
}
fn read_text(&self, owner: &str, project: &str, repo_path: &str) -> Option<String> {
self.platform.projects.read_repo_file_text(owner, project, repo_path).ok()
}
fn journal(&self, owner: &str, project: &str) -> BTreeMap<String, bool> {
self.read_text(owner, project, JOURNAL_FILE)
.and_then(|text| serde_json::from_str::<Value>(&text).ok())
.and_then(|doc| doc.get("pipelines").cloned())
.and_then(|p| p.as_object().cloned())
.map(|map| {
map.into_iter()
.filter_map(|(k, v)| v.get("was_active").and_then(Value::as_bool).map(|a| (k, a)))
.collect()
})
.unwrap_or_default()
}
pub fn plan_project(&self, owner: &str, project: &str) -> Result<Plan, PlatformError> {
let projects = &self.platform.projects;
let layout = projects.project_layout(owner, project)?;
let source_root = layout.repo_layout.source.clone();
let journal = self.journal(owner, project);
let mut pipelines = Vec::new();
for meta in projects.list_pipeline_meta_rows(owner, project)? {
let repo_path = layout.repo_layout.source_rel(&meta.file_rel_path);
if crate::platform::model::is_migration_archive_path(&repo_path) {
continue;
}
let live = projects.read_pipeline_source(owner, project, &meta.file_rel_path)?;
pipelines.push(PipelineSource {
archived: self.read_text(owner, project, &archive_path(&repo_path)),
repo_path,
live,
active: meta.active_hash.is_some(),
draft: meta.active_hash.as_deref().is_some_and(|active| active != meta.hash),
was_active: journal.get(&meta.file_rel_path).copied(),
title: meta.title.clone(),
description: meta.description.clone(),
trigger_kind: meta.trigger_kind.clone(),
file_rel_path: meta.file_rel_path,
});
}
let credential_kinds = self
.platform
.credentials
.list_project_credentials(owner, project)
.map(|items| items.into_iter().map(|c| (c.credential_id, c.kind)).collect())
.unwrap_or_default();
let read_page = |repo_path: &str| {
(self.read_text(owner, project, repo_path), self.read_text(owner, project, &archive_path(repo_path)))
};
let check = |file_rel_path: &str, doc: &Value| -> Result<Checked, String> {
let source = serde_json::to_string_pretty(doc).map_err(|e| e.to_string())?;
let (canonical, check) = projects
.preview_pipeline_save(owner, project, file_rel_path, &source)
.map_err(|e| e.message)?;
Ok(Checked {
canonical,
refusals: check.refusals.iter().map(|p| p.to_string()).collect(),
warnings: check.warnings.iter().map(|p| p.to_string()).collect(),
})
};
let inputs = PlanInputs {
owner,
project,
source_root: &source_root,
credential_kinds,
read_page: &read_page,
check: &check,
};
Ok(plan_sources(&inputs, &pipelines))
}
pub async fn apply_plan(&self, owner: &str, project: &str, fingerprint: &str) -> Result<ApplyReport, PlatformError> {
let Some(switch) = self.platform.projects.pipeline_switch() else {
return Err(PlatformError::new(
"PLATFORM_MIGRATION_NO_RUNTIME",
"the migration switches pipelines through the running web layer, and none is attached",
));
};
let switch = switch.as_ref();
let plan = self.plan_project(owner, project)?;
if plan.fingerprint != fingerprint.trim() {
return Err(PlatformError::new(
"PLATFORM_MIGRATION_STALE",
format!(
"the project changed since that plan (its fingerprint is now {}); read the plan again and apply that one",
plan.fingerprint
),
));
}
if !plan.ready {
return Err(PlatformError::new(
"PLATFORM_MIGRATION_BLOCKED",
format!(
"the plan has {} unresolved item(s) and {} refused check(s); resolve them first",
plan.counts.unresolved, plan.counts.refusals
),
));
}
let projects = &self.platform.projects;
let mut done = Vec::new();
let mut failed = Vec::new();
let mut journal = self.journal(owner, project);
let mut journal_changed = false;
for file in plan.files.iter().filter(|f| f.kind == FileKind::Pipeline && f.status == Status::Rewrite) {
let key = file.file_rel_path.clone().unwrap_or_default();
if !journal.contains_key(&key) {
journal.insert(key, file.active);
journal_changed = true;
}
}
if journal_changed {
let doc = json!({
"migration": "0.10 → 0.11",
"pipelines": journal.iter().map(|(k, v)| (k.clone(), json!({ "was_active": v }))).collect::<serde_json::Map<_, _>>(),
});
projects.write_repo_file(owner, project, JOURNAL_FILE, &serde_json::to_string_pretty(&doc).unwrap_or_default())?;
}
for file in plan.files.iter().filter(|f| f.kind == FileKind::Pipeline && f.status == Status::Rewrite) {
let frp = file.file_rel_path.clone().unwrap_or_default();
let currently_active = projects
.get_pipeline_meta_by_file_id(owner, project, &frp)?
.is_some_and(|m| m.active_hash.is_some());
if currently_active && let Err(e) = switch.deactivate(owner, project, &frp).await {
failed.push(format!("deactivate pipeline `{}`: {}", file.path, e.message));
continue;
}
let archived = archive_path(&file.path);
if self.read_text(owner, project, &archived).is_none()
&& let Err(e) = projects.write_repo_file(owner, project, &archived, &file.original)
{
failed.push(format!("archive pipeline `{}`: {}", file.path, e.message));
continue;
}
match projects.upsert_pipeline_definition(
owner,
project,
&frp,
&file.title,
&file.description,
&file.trigger_kind,
&file.new_text,
) {
Ok(_) => done.push(format!("pipeline `{}` archived to `{archived}` and replaced by its 0.11 rewrite", file.path)),
Err(e) => failed.push(format!("register the rewrite of `{}`: {}", file.path, e.message)),
}
}
for file in plan.files.iter().filter(|f| f.kind == FileKind::Page && f.status == Status::Rewrite) {
let archived = archive_path(&file.path);
if self.read_text(owner, project, &archived).is_none()
&& let Err(e) = projects.write_repo_file(owner, project, &archived, &file.original)
{
failed.push(format!("archive page `{}`: {}", file.path, e.message));
continue;
}
match projects.write_repo_file(owner, project, &file.path, &file.new_text) {
Ok(_) => done.push(format!("page `{}` archived to `{archived}` and rewritten", file.path)),
Err(e) => failed.push(format!("write page `{}`: {}", file.path, e.message)),
}
}
let mut to_activate: Vec<_> = plan
.files
.iter()
.filter(|f| f.kind == FileKind::Pipeline)
.filter(|f| (f.status == Status::Rewrite && f.active) || f.needs_activation)
.collect();
to_activate.sort_by_key(|f| !f.function);
for file in to_activate {
let frp = file.file_rel_path.clone().unwrap_or_default();
if failed.iter().any(|f| f.contains(&format!("`{}`", file.path))) {
continue;
}
match switch.activate(owner, project, &frp).await {
Ok(()) => done.push(format!("pipeline `{}` activated", file.path)),
Err(e) => failed.push(format!("activate pipeline `{}`: {}", file.path, e.message)),
}
}
if !done.is_empty() || !failed.is_empty() {
let at = chrono::Utc::now().format("%Y-%m-%d %H:%M UTC").to_string();
let mut record = self.read_text(owner, project, REPORT_FILE).unwrap_or_else(|| {
format!(
"# 0.10 → 0.11 migration\n\nThe 0.10 originals of every file the migration rewrote are kept under `{ARCHIVE_PREFIX}/`, \
at the path they had. Nothing under `archive/` is loaded as a pipeline. Delete the folder when the 0.11 project is verified.\n"
)
});
record.push_str(&report::apply_markdown(&plan, &done, &failed, &at));
projects.write_repo_file(owner, project, REPORT_FILE, &record)?;
}
if failed.is_empty() {
self.platform.migration_notices.clear(owner, project);
}
Ok(ApplyReport { fingerprint: plan.fingerprint, ok: failed.is_empty(), done, failed })
}
}