use std::path::{Path, PathBuf};
use serde::Deserialize;
use crate::pipeline::{Facet, Medium, MediumType, PatternEntry, PatternMode, Projection};
use crate::pipeline_store::LegacyIngest;
use crate::pipeline_store::{MemPipelineRecord, PipelineConfigs, PipelineRecord};
use crate::workspace_store::StoreError;
#[derive(Debug, Deserialize)]
struct LegacyScope {
#[serde(rename = "type")]
medium_type: MediumType,
#[serde(default)]
#[allow(dead_code)]
label: Option<String>,
scope: LegacyScopeBody,
}
#[derive(Debug, Deserialize)]
struct LegacyScopeBody {
#[serde(default)]
tree: Vec<PatternEntry>,
}
#[derive(Debug, Deserialize)]
struct LegacyProjection {
#[serde(default)]
intent: Option<String>,
#[serde(default)]
sources: Vec<LegacySource>,
#[serde(default)]
destinations: Vec<LegacyDestination>,
}
#[derive(Debug, Deserialize)]
struct LegacySource {
#[serde(default)]
scope_ref: Option<String>,
#[serde(default)]
mem: Option<String>,
}
#[derive(Debug, Deserialize)]
struct LegacyDestination {
mem: String,
}
fn common_dir_prefix(tree: &[PatternEntry]) -> String {
let allows: Vec<Vec<&str>> = tree
.iter()
.filter(|e| e.mode == PatternMode::Allow)
.map(|e| e.path.split('/').collect())
.collect();
let Some(first) = allows.first() else {
return String::new();
};
let mut prefix: Vec<&str> = Vec::new();
'outer: for (i, comp) in first.iter().enumerate() {
if comp.contains('*') {
break;
}
for other in &allows[1..] {
if other.get(i) != Some(comp) {
break 'outer;
}
}
prefix.push(comp);
}
prefix.join("/")
}
fn convert_scope(name: &str, scope: LegacyScope) -> (Medium, Facet) {
let pointer = common_dir_prefix(&scope.scope.tree);
let medium = Medium {
name: name.to_string(),
medium_type: scope.medium_type,
pointer,
change_detection: None,
};
let facet = Facet {
name: name.to_string(),
medium: name.to_string(),
scope: scope.scope.tree,
engagement: None,
preparation: None,
};
(medium, facet)
}
fn convert_projection(p: LegacyProjection) -> Projection {
let mut source_facets = Vec::new();
let mut reference_mems = Vec::new();
for s in p.sources {
if let Some(scope_ref) = s.scope_ref {
source_facets.push(scope_ref);
} else if let Some(mem) = s.mem {
reference_mems.push(mem);
}
}
Projection {
intent: p.intent,
source_facets,
reference_mems,
destination_mem: p
.destinations
.into_iter()
.next()
.map(|d| d.mem)
.unwrap_or_default(),
rules: None,
}
}
fn read_legacy_json<T: serde::de::DeserializeOwned>(path: &Path) -> Result<T, StoreError> {
let bytes = std::fs::read(path).map_err(|e| StoreError::Io {
path: path.to_path_buf(),
source: e,
})?;
serde_json::from_slice(&bytes).map_err(|e| StoreError::Parse {
path: path.to_path_buf(),
message: e.to_string(),
})
}
fn walk_mem_scoped(
root: &Path,
primitive: &str,
) -> Result<Vec<(String, String, PathBuf)>, StoreError> {
let dir = root.join(primitive);
let mut out = Vec::new();
let mem_dirs = match std::fs::read_dir(&dir) {
Ok(rd) => rd,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(out),
Err(e) => {
return Err(StoreError::Io {
path: dir,
source: e,
});
}
};
for mem_entry in mem_dirs.flatten() {
let mem_path = mem_entry.path();
if !mem_path.is_dir() {
continue;
}
let mem = mem_entry.file_name().to_string_lossy().into_owned();
for file in std::fs::read_dir(&mem_path)
.map_err(|e| StoreError::Io {
path: mem_path.clone(),
source: e,
})?
.flatten()
{
let path = file.path();
if path.extension().and_then(|e| e.to_str()) != Some("json") {
continue;
}
if let Some(name) = path.file_stem().map(|s| s.to_string_lossy().into_owned()) {
out.push((mem.clone(), name, path));
}
}
}
out.sort_by(|a, b| (a.0.as_str(), a.1.as_str()).cmp(&(b.0.as_str(), b.1.as_str())));
Ok(out)
}
pub fn read_legacy_pipeline_configs(workspace_root: &Path) -> Result<PipelineConfigs, StoreError> {
let mut configs = PipelineConfigs::default();
for (mem, name, path) in walk_mem_scoped(workspace_root, "scopes")? {
let scope: LegacyScope = read_legacy_json(&path)?;
let (medium, facet) = convert_scope(&name, scope);
configs.mediums.push(MemPipelineRecord {
mem: mem.clone(),
name: name.clone(),
config: medium,
});
configs.facets.push(MemPipelineRecord {
mem,
name,
config: facet,
});
}
for (mem, name, path) in walk_mem_scoped(workspace_root, "projections")? {
let legacy: LegacyProjection = read_legacy_json(&path)?;
configs.projections.push(MemPipelineRecord {
mem,
name,
config: convert_projection(legacy),
});
}
let ingests_dir = workspace_root.join("ingests");
match std::fs::read_dir(&ingests_dir) {
Ok(rd) => {
let mut ingests = Vec::new();
for file in rd.flatten() {
let path = file.path();
if path.extension().and_then(|e| e.to_str()) != Some("json") {
continue;
}
if let Some(name) = path.file_stem().map(|s| s.to_string_lossy().into_owned()) {
let config: LegacyIngest = read_legacy_json(&path)?;
ingests.push(PipelineRecord { name, config });
}
}
ingests.sort_by(|a, b| a.name.cmp(&b.name));
configs.ingests = ingests;
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => {
return Err(StoreError::Io {
path: ingests_dir,
source: e,
});
}
}
Ok(configs)
}
pub fn migrate_legacy_pipeline(workspace_root: &Path) -> Result<PipelineConfigs, StoreError> {
let configs = read_legacy_pipeline_configs(workspace_root)?;
for m in &configs.mediums {
crate::pipeline_store::write_medium(workspace_root, &m.mem, &m.name, &m.config)?;
}
for f in &configs.facets {
crate::pipeline_store::write_facet(workspace_root, &f.mem, &f.name, &f.config)?;
}
for p in &configs.projections {
crate::pipeline_store::write_projection(workspace_root, &p.mem, &p.name, &p.config)?;
}
for i in &configs.ingests {
crate::pipeline_store::write_ingest(workspace_root, &i.name, &i.config)?;
}
Ok(configs)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::pipeline_store::LegacyIngestMode;
#[test]
fn common_dir_prefix_stops_at_first_glob() {
let tree = vec![
PatternEntry {
path: "../legacy-project/**/*.rs".into(),
mode: PatternMode::Allow,
},
PatternEntry {
path: "../legacy-project/Cargo.lock".into(),
mode: PatternMode::Allow,
},
PatternEntry {
path: "../legacy-project/target/**".into(),
mode: PatternMode::Deny,
},
];
assert_eq!(common_dir_prefix(&tree), "../legacy-project");
}
#[test]
fn common_dir_prefix_falls_back_to_shared_ancestor() {
let tree = vec![
PatternEntry {
path: "../dev/**/*.md".into(),
mode: PatternMode::Allow,
},
PatternEntry {
path: "../VISION.md".into(),
mode: PatternMode::Allow,
},
PatternEntry {
path: "../macos/VISION.md".into(),
mode: PatternMode::Allow,
},
];
assert_eq!(common_dir_prefix(&tree), "..");
}
#[test]
fn scope_splits_into_medium_and_facet() {
let scope = LegacyScope {
medium_type: MediumType::Codebase,
label: Some("Rust engine source".into()),
scope: LegacyScopeBody {
tree: vec![
PatternEntry {
path: "../legacy-project/**/*.rs".into(),
mode: PatternMode::Allow,
},
PatternEntry {
path: "../legacy-project/target/**".into(),
mode: PatternMode::Deny,
},
],
},
};
let (medium, facet) = convert_scope("source-tree", scope);
assert_eq!(medium.name, "source-tree");
assert_eq!(medium.medium_type, MediumType::Codebase);
assert_eq!(medium.pointer, "../legacy-project");
assert_eq!(facet.name, "source-tree");
assert_eq!(facet.medium, "source-tree");
assert_eq!(facet.scope.len(), 2);
assert_eq!(facet.preparation, None);
assert_eq!(facet.engagement, None);
}
#[test]
fn projection_sources_split_into_facets_and_reference_mems() {
let legacy = LegacyProjection {
intent: Some("macOS source".into()),
sources: vec![
LegacySource {
scope_ref: Some("source-tree".into()),
mem: None,
},
LegacySource {
scope_ref: None,
mem: Some("engine".into()),
},
],
destinations: vec![LegacyDestination {
mem: "macos".into(),
}],
};
let p = convert_projection(legacy);
assert_eq!(p.source_facets, vec!["source-tree".to_string()]);
assert_eq!(p.reference_mems, vec!["engine".to_string()]);
assert_eq!(p.destination_mem, "macos");
assert_eq!(p.intent.as_deref(), Some("macOS source"));
}
#[test]
fn migrate_reads_legacy_tree_and_writes_store_idempotently() {
let tmp = tempfile::TempDir::new().unwrap();
let root = tmp.path();
std::fs::create_dir_all(root.join("scopes/macos")).unwrap();
std::fs::write(
root.join("scopes/macos/source-tree.json"),
r#"{"type":"codebase","label":"macOS","scope":{"tree":[{"path":"../macos/**/*.swift","mode":"allow"}]}}"#,
).unwrap();
std::fs::create_dir_all(root.join("projections/macos")).unwrap();
std::fs::write(
root.join("projections/macos/graph.json"),
r#"{"intent":"i","sources":[{"role":"primary","scope_ref":"source-tree"},{"role":"reference","mem":"engine"}],"destinations":[{"mem":"macos"}]}"#,
).unwrap();
std::fs::create_dir_all(root.join("ingests")).unwrap();
std::fs::write(
root.join("ingests/macos-graph.json"),
r#"{"projection":"macos/graph","mode":"discovery","trigger":"loop","batch_size":20,"deny_paths":["dev"]}"#,
).unwrap();
let converted = migrate_legacy_pipeline(root).unwrap();
assert_eq!(converted.mediums.len(), 1);
assert_eq!(converted.mediums[0].config.pointer, "../macos");
assert_eq!(converted.facets.len(), 1);
assert_eq!(
converted.projections[0].config.reference_mems,
vec!["engine".to_string()]
);
assert_eq!(
converted.ingests[0].config.mode,
LegacyIngestMode::Discovery
);
let loaded = crate::pipeline_store::load_legacy_pipeline_configs(root).unwrap();
assert_eq!(loaded, converted);
let again = migrate_legacy_pipeline(root).unwrap();
assert_eq!(again, converted);
assert_eq!(
crate::pipeline_store::load_legacy_pipeline_configs(root).unwrap(),
converted
);
}
}