use std::collections::{BTreeMap, BTreeSet};
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use crate::actions::resources::export_zip_bytes;
use crate::client::GatewayApi;
use crate::client::resources::{
FOLDER_DESCRIPTOR, fnv1a, member_hashes, normalize_descriptor, read_member, remove_member,
replace_member, resource_members,
};
use crate::client::scripts_codec;
use crate::client::workspace::{MemberSource, build_mapping};
use crate::error::CoreError;
pub const WORKSPACE_MANIFEST_NAME: &str = ".ign-workspace.json";
pub const WORKSPACE_MANIFEST_SCHEMA_VERSION: u8 = 1;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct WorkspaceManifest {
pub schema_version: u8,
pub project: String,
pub profile: String,
pub checked_out_at: String,
pub members: BTreeMap<String, ManifestMember>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ManifestMember {
pub local_path: String,
pub hash: u64,
}
pub fn read_manifest(root: &Path) -> Result<WorkspaceManifest, CoreError> {
let path = root.join(WORKSPACE_MANIFEST_NAME);
let bytes = std::fs::read(&path).map_err(|_| CoreError::InvalidInput {
reason: format!(
"not an ign workspace — run `ign workspace checkout` first \
(expected manifest at {})",
path.display()
),
})?;
let manifest: WorkspaceManifest =
serde_json::from_slice(&bytes).map_err(|err| CoreError::InvalidInput {
reason: format!("{} is not valid JSON: {err}", path.display()),
})?;
if manifest.schema_version != WORKSPACE_MANIFEST_SCHEMA_VERSION {
return Err(CoreError::InvalidInput {
reason: format!(
"unsupported workspace manifest schema_version {} (expected \
{}) at {}",
manifest.schema_version,
WORKSPACE_MANIFEST_SCHEMA_VERSION,
path.display()
),
});
}
Ok(manifest)
}
pub fn write_manifest(root: &Path, manifest: &WorkspaceManifest) -> Result<(), CoreError> {
let path = root.join(WORKSPACE_MANIFEST_NAME);
std::fs::create_dir_all(root).map_err(|err| {
CoreError::Internal(format!(
"cannot create workspace root {}: {err}",
root.display()
))
})?;
let mut body = serde_json::to_vec_pretty(manifest).map_err(|err| {
CoreError::Internal(format!("cannot serialize workspace manifest: {err}"))
})?;
body.push(b'\n');
std::fs::write(&path, body)
.map_err(|err| CoreError::Internal(format!("cannot write {}: {err}", path.display())))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o640)).map_err(|err| {
CoreError::Internal(format!(
"cannot set permissions on {}: {err}",
path.display()
))
})?;
}
Ok(())
}
const WORKSPACE_GITIGNORE_LINES: [&str; 2] = [scripts_codec::MANIFEST_NAME, "*.py"];
const RESERVED_ROOT_NAMES: [&str; 4] = [
WORKSPACE_MANIFEST_NAME,
scripts_codec::MANIFEST_NAME,
".gitignore",
"project.json",
];
#[derive(Debug, Clone, Serialize)]
pub struct CheckoutOutcome {
pub project: String,
pub target: PathBuf,
pub member_count: usize,
pub scripts_decoded: bool,
}
pub async fn workspace_checkout(
api: &dyn GatewayApi,
project: &str,
target_dir: &Path,
profile: &str,
decode_scripts: bool,
) -> Result<CheckoutOutcome, CoreError> {
let zip = export_zip_bytes(api, project).await?;
ensure_recheckout_safe(target_dir, project)?;
let members = resource_members(&zip)?;
let mapping = build_mapping(&members)?;
for (user, local) in &mapping {
let root_name = local.file_name().and_then(|name| name.to_str());
let root_level = local
.parent()
.is_none_or(|parent| parent.as_os_str().is_empty());
if root_level && root_name.is_some_and(|name| RESERVED_ROOT_NAMES.contains(&name)) {
return Err(CoreError::InvalidInput {
reason: format!(
"workspace member \"{user}\" maps onto reserved workspace file \
\"{}\" — the checkout owns this name; refusing",
local.display()
),
});
}
}
std::fs::create_dir_all(target_dir).map_err(|err| {
CoreError::Internal(format!(
"cannot create workspace root {}: {err}",
target_dir.display()
))
})?;
let mapped: BTreeSet<&PathBuf> = mapping.values().collect();
let mut codec_manifest = scripts_codec::Manifest {
version: 1,
members: BTreeMap::new(),
};
for (user, local) in &mapping {
let bytes = read_member(&zip, user)?;
let dest = target_dir.join(local);
if let Some(parent) = dest.parent() {
std::fs::create_dir_all(parent).map_err(|err| {
CoreError::Internal(format!("cannot create {}: {err}", parent.display()))
})?;
}
std::fs::write(&dest, &bytes).map_err(|err| {
CoreError::Internal(format!("cannot write {}: {err}", dest.display()))
})?;
if decode_scripts && let Some(decoded) = scripts_codec::decode_member(&bytes, user) {
let mut entries = Vec::with_capacity(decoded.entries.len());
for decoded_entry in decoded.entries {
let sidecar_local = match local.parent() {
Some(parent) if !parent.as_os_str().is_empty() => {
parent.join(&decoded_entry.entry.sidecar)
}
_ => PathBuf::from(&decoded_entry.entry.sidecar),
};
if mapped.contains(&sidecar_local) {
return Err(CoreError::InvalidInput {
reason: format!(
"sidecar \"{}\" of member \"{user}\" collides \
with a real checkout member — refusing to shadow it",
sidecar_local.display()
),
});
}
let sidecar_dest = target_dir.join(&sidecar_local);
std::fs::write(&sidecar_dest, &decoded_entry.text).map_err(|err| {
CoreError::Internal(format!("cannot write {}: {err}", sidecar_dest.display()))
})?;
entries.push(decoded_entry.entry);
}
codec_manifest
.members
.insert(scripts_codec::tree_relative_string(local), entries);
}
}
if decode_scripts {
let mut manifest_bytes = serde_json::to_vec_pretty(&codec_manifest).map_err(|err| {
CoreError::Internal(format!("cannot serialize the decode manifest: {err}"))
})?;
manifest_bytes.push(b'\n');
let manifest_path = target_dir.join(scripts_codec::MANIFEST_NAME);
std::fs::write(&manifest_path, manifest_bytes).map_err(|err| {
CoreError::Internal(format!("cannot write {}: {err}", manifest_path.display()))
})?;
}
let hashes = member_hashes(&zip)?;
let mut manifest_members = BTreeMap::new();
for (user, hash) in hashes {
let local = &mapping[&user];
manifest_members.insert(
user,
ManifestMember {
local_path: scripts_codec::tree_relative_string(local),
hash,
},
);
}
let manifest = WorkspaceManifest {
schema_version: WORKSPACE_MANIFEST_SCHEMA_VERSION,
project: project.to_string(),
profile: profile.to_string(),
checked_out_at: rfc3339_now_utc(),
members: manifest_members,
};
write_manifest(target_dir, &manifest)?;
write_gitignore(target_dir)?;
Ok(CheckoutOutcome {
project: project.to_string(),
target: target_dir.to_path_buf(),
member_count: mapping.len(),
scripts_decoded: decode_scripts,
})
}
fn ensure_recheckout_safe(target_dir: &Path, project: &str) -> Result<(), CoreError> {
if !target_dir.exists() {
return Ok(()); }
let entries = std::fs::read_dir(target_dir).map_err(|err| CoreError::InvalidInput {
reason: format!(
"target directory {} is not a usable checkout target: {err}",
target_dir.display()
),
})?;
if entries.count() == 0 {
return Ok(()); }
match read_manifest(target_dir) {
Ok(manifest) => {
if manifest.project != project {
return Err(CoreError::InvalidInput {
reason: format!(
"workspace at {} is checked out from project {:?} — refusing \
to check out {:?} over it",
target_dir.display(),
manifest.project,
project
),
});
}
Ok(()) }
Err(err) => Err(CoreError::InvalidInput {
reason: format!(
"target directory {} is not empty and is not an ign workspace — \
refusing to clobber it ({err})",
target_dir.display()
),
}),
}
}
fn write_gitignore(root: &Path) -> Result<(), CoreError> {
let path = root.join(".gitignore");
let mut lines: Vec<String> = std::fs::read_to_string(&path)
.map(|content| content.lines().map(str::to_string).collect())
.unwrap_or_default();
let mut changed = false;
for entry in WORKSPACE_GITIGNORE_LINES {
if !lines.iter().any(|line| line.trim() == entry) {
lines.push(entry.to_string());
changed = true;
}
}
if !changed {
return Ok(()); }
let mut body = lines.join("\n");
body.push('\n');
std::fs::write(&path, body)
.map_err(|err| CoreError::Internal(format!("cannot write {}: {err}", path.display())))
}
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct WorkspaceStatus {
pub project: String,
pub clean: bool,
pub rows: Vec<StatusRow>,
}
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct StatusRow {
pub path: String,
pub kind: StatusKind,
}
#[derive(Debug, Clone, PartialEq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum StatusKind {
Clean,
LocalEdit,
GatewayDrift,
Conflict,
Added {
local: bool,
},
Deleted {
local: bool,
},
Untracked,
}
pub fn classify(manifest: Option<u64>, local: Option<u64>, gateway: Option<u64>) -> StatusKind {
let Some(m) = manifest else {
return if local == gateway {
StatusKind::Clean
} else {
StatusKind::Conflict
};
};
if local.is_none() && gateway.is_none() {
return StatusKind::Clean;
}
let local_same = local == Some(m);
let gateway_same = gateway == Some(m);
match (local_same, gateway_same) {
(true, true) => StatusKind::Clean,
(false, true) => StatusKind::LocalEdit,
(true, false) => StatusKind::GatewayDrift,
(false, false) => StatusKind::Conflict,
}
}
fn local_member_hash(
root: &Path,
recorded: &ManifestMember,
member: &str,
) -> Result<Option<u64>, CoreError> {
match std::fs::read(root.join(&recorded.local_path)) {
Ok(bytes) => {
let is_descriptor = Path::new(&recorded.local_path).file_name()
== Some(std::ffi::OsStr::new(FOLDER_DESCRIPTOR));
let content = if is_descriptor {
normalize_descriptor(&bytes).unwrap_or(bytes)
} else {
bytes
};
Ok(Some(fnv1a(&content)))
}
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(err) => Err(CoreError::InvalidInput {
reason: format!(
"workspace member \"{member}\" cannot be read from the checkout \
tree (expected at \"{}\"): {err}",
recorded.local_path
),
}),
}
}
fn workspace_owned_or_artifact(rel: &str) -> bool {
rel == WORKSPACE_MANIFEST_NAME
|| rel == ".gitignore"
|| rel == scripts_codec::MANIFEST_NAME
|| rel.ends_with(".py")
}
fn untracked_files(
root: &Path,
member_locals: &BTreeSet<String>,
) -> Result<Vec<String>, CoreError> {
fn walk(dir: &Path, prefix: &str, found: &mut BTreeSet<String>) -> std::io::Result<()> {
for entry in std::fs::read_dir(dir)? {
let entry = entry?;
let name = entry.file_name().to_string_lossy().into_owned();
let rel = if prefix.is_empty() {
name
} else {
format!("{prefix}/{name}")
};
if entry.path().is_dir() {
walk(&entry.path(), &rel, found)?;
} else if entry.path().is_file() {
found.insert(rel);
}
}
Ok(())
}
let mut found = BTreeSet::new();
walk(root, "", &mut found).map_err(|err| {
CoreError::Internal(format!("cannot walk workspace tree {root:?}: {err}"))
})?;
Ok(found
.into_iter()
.filter(|rel| !workspace_owned_or_artifact(rel) && !member_locals.contains(rel))
.collect())
}
pub async fn workspace_status(
root: &Path,
api: &dyn GatewayApi,
project: &str,
) -> Result<WorkspaceStatus, CoreError> {
let manifest = read_manifest(root)?;
if manifest.project != project {
return Err(CoreError::InvalidInput {
reason: format!(
"workspace at {} is checked out from project {:?} — refusing to \
status {:?}",
root.display(),
manifest.project,
project
),
});
}
let zip = export_zip_bytes(api, project).await?;
let gateway_hashes = MemberSource::Zip(zip).member_hashes()?;
let mut rows = Vec::new();
for (member, recorded) in &manifest.members {
let local = local_member_hash(root, recorded, member)?;
let gateway = gateway_hashes.get(member).copied();
let kind = match classify(Some(recorded.hash), local, gateway) {
StatusKind::LocalEdit if local.is_none() => StatusKind::Deleted { local: true },
StatusKind::GatewayDrift if gateway.is_none() => StatusKind::Deleted { local: false },
other => other,
};
rows.push(StatusRow {
path: member.clone(),
kind,
});
}
for member in gateway_hashes.keys() {
if !manifest.members.contains_key(member) {
rows.push(StatusRow {
path: member.clone(),
kind: StatusKind::Added { local: false },
});
}
}
let member_locals: BTreeSet<String> = manifest
.members
.values()
.map(|recorded| recorded.local_path.clone())
.collect();
for rel in untracked_files(root, &member_locals)? {
rows.push(StatusRow {
path: rel,
kind: StatusKind::Untracked,
});
}
let clean = rows.iter().all(|row| matches!(row.kind, StatusKind::Clean));
Ok(WorkspaceStatus {
project: project.to_string(),
clean,
rows,
})
}
#[derive(Debug, Clone, Serialize)]
pub struct PushPreview {
pub rows: Vec<StatusRow>,
pub would_write: Vec<String>,
pub would_delete: Vec<String>,
}
#[derive(Debug, Clone, Serialize)]
pub struct PushOutcome {
pub project: String,
pub wrote: Vec<String>,
pub deleted: Vec<String>,
pub skipped: Vec<String>,
}
fn render_push_preview(preview: &PushPreview) -> String {
let mut lines = vec![format!(
"workspace push would write {} member(s) and delete {} member(s)",
preview.would_write.len(),
preview.would_delete.len()
)];
for path in &preview.would_write {
lines.push(format!(" write: {path}"));
}
for path in &preview.would_delete {
lines.push(format!(" delete: {path}"));
}
lines.join("\n")
}
fn require_confirmation(yes: bool, preview: &PushPreview) -> Result<(), CoreError> {
if yes {
return Ok(());
}
Err(CoreError::ConfirmationRequired {
operation: render_push_preview(preview),
})
}
pub async fn workspace_push(
root: &Path,
api: &dyn GatewayApi,
project: &str,
yes: bool,
delete: bool,
) -> Result<PushOutcome, CoreError> {
let status = workspace_status(root, api, project).await?;
let conflicts: Vec<&str> = status
.rows
.iter()
.filter_map(|row| matches!(row.kind, StatusKind::Conflict).then_some(row.path.as_str()))
.collect();
if !conflicts.is_empty() {
let named: Vec<String> = conflicts.iter().map(|path| format!("\"{path}\"")).collect();
return Err(CoreError::InvalidInput {
reason: format!(
"workspace push refused — {} member(s) changed on BOTH sides since \
checkout: {}; pull a fresh checkout or reconcile manually — conflicts \
are never force-pushed",
conflicts.len(),
named.join(", ")
),
});
}
let would_write: Vec<String> = status
.rows
.iter()
.filter_map(|row| matches!(row.kind, StatusKind::LocalEdit).then_some(row.path.clone()))
.collect();
let locally_deleted: Vec<String> = status
.rows
.iter()
.filter_map(|row| {
matches!(row.kind, StatusKind::Deleted { local: true }).then_some(row.path.clone())
})
.collect();
let (would_delete, mut skipped): (Vec<String>, Vec<String>) = if delete {
(locally_deleted.clone(), Vec::new())
} else {
(Vec::new(), locally_deleted.clone())
};
if would_write.is_empty() && would_delete.is_empty() {
return Ok(PushOutcome {
project: project.to_string(),
wrote: Vec::new(),
deleted: Vec::new(),
skipped,
});
}
let preview = PushPreview {
rows: status.rows.clone(),
would_write: would_write.clone(),
would_delete: would_delete.clone(),
};
require_confirmation(yes, &preview)?;
let manifest = read_manifest(root)?;
let fresh = export_zip_bytes(api, project).await?;
let mut spliced = fresh;
let mut wrote = Vec::new();
for member in &would_write {
let recorded = &manifest.members[member];
let bytes = std::fs::read(root.join(&recorded.local_path)).map_err(|err| {
CoreError::InvalidInput {
reason: format!(
"workspace member \"{member}\" cannot be read from the checkout \
tree (expected at \"{}\"): {err}",
recorded.local_path
),
}
})?;
spliced = replace_member(&spliced, member, &bytes)?;
wrote.push(member.clone());
}
let mut deleted = Vec::new();
for member in &would_delete {
match remove_member(&spliced, member) {
Ok(next) => {
spliced = next;
deleted.push(member.clone());
}
Err(CoreError::NotFound { .. }) => skipped.push(member.clone()),
Err(other) => return Err(other),
}
}
api.project_import(project, spliced, true).await?;
Ok(PushOutcome {
project: project.to_string(),
wrote,
deleted,
skipped,
})
}
fn rfc3339_now_utc() -> String {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("system clock is after the unix epoch");
unix_ms_to_rfc3339_utc(now.as_millis() as i64)
}
fn unix_ms_to_rfc3339_utc(ms: i64) -> String {
let secs = ms.div_euclid(1000);
let millis = ms.rem_euclid(1000);
let days = secs.div_euclid(86_400);
let sod = secs.rem_euclid(86_400); let z = days + 719_468;
let era = z.div_euclid(146_097);
let doe = z.rem_euclid(146_097); let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); let mp = (5 * doy + 2) / 153; let d = doy - (153 * mp + 2) / 5 + 1; let m = if mp < 10 { mp + 3 } else { mp - 9 }; let y = yoe + era * 400 + i64::from(m <= 2);
format!(
"{y:04}-{m:02}-{d:02}T{hh:02}:{mm:02}:{ss:02}.{millis:03}Z",
hh = sod / 3600,
mm = (sod % 3600) / 60,
ss = sod % 60,
)
}
#[cfg(test)]
mod manifest_tests {
use super::*;
fn sample() -> WorkspaceManifest {
let mut members = BTreeMap::new();
members.insert(
"com.example/views/Dashboard/view.json".to_string(),
ManifestMember {
local_path: "com.example/views/Dashboard/view.json".to_string(),
hash: 0xDEAD_BEEF,
},
);
members.insert(
"ignition/script-python/e2e/scratch".to_string(),
ManifestMember {
local_path: "ignition/script-python/e2e/scratch".to_string(),
hash: 42,
},
);
WorkspaceManifest {
schema_version: WORKSPACE_MANIFEST_SCHEMA_VERSION,
project: "My Proj".to_string(),
profile: "rig".to_string(),
checked_out_at: "2026-09-15T02:25:19.000Z".to_string(),
members,
}
}
#[test]
fn manifest_round_trips_write_read() {
let root = tempfile::tempdir().expect("tempdir");
let manifest = sample();
write_manifest(root.path(), &manifest).expect("writes");
let read = read_manifest(root.path()).expect("reads back");
assert_eq!(read, manifest, "write→read is lossless");
let first = std::fs::read(root.path().join(WORKSPACE_MANIFEST_NAME)).expect("read 1");
write_manifest(root.path(), &manifest).expect("rewrites");
let second = std::fs::read(root.path().join(WORKSPACE_MANIFEST_NAME)).expect("read 2");
assert_eq!(first, second, "serialization is deterministic");
assert!(
first.ends_with(b"\n"),
"the manifest file ends with a trailing newline"
);
}
#[test]
fn missing_manifest_refuses_with_stable_prefix() {
let root = tempfile::tempdir().expect("tempdir");
let err = read_manifest(root.path()).expect_err("missing refuses");
assert!(matches!(err, CoreError::InvalidInput { .. }), "{err}");
assert_eq!(err.exit_code(), 2);
let message = err.to_string();
assert!(
message.contains("not an ign workspace — run `ign workspace checkout` first"),
"stable prefix missing: {message}"
);
assert!(
message.contains(WORKSPACE_MANIFEST_NAME),
"names the expected path: {message}"
);
}
#[test]
fn schema_version_mismatch_refuses() {
let root = tempfile::tempdir().expect("tempdir");
let mut manifest = sample();
manifest.schema_version = 99;
write_manifest(root.path(), &manifest).expect("writes foreign version");
let err = read_manifest(root.path()).expect_err("mismatch refuses");
assert!(matches!(err, CoreError::InvalidInput { .. }), "{err}");
let message = err.to_string();
assert!(
message.contains("schema_version 99") && message.contains("expected 1"),
"names found vs expected: {message}"
);
}
#[test]
fn corrupt_manifest_refuses() {
let root = tempfile::tempdir().expect("tempdir");
std::fs::write(root.path().join(WORKSPACE_MANIFEST_NAME), b"{not json").expect("writes");
let err = read_manifest(root.path()).expect_err("corrupt refuses");
assert!(matches!(err, CoreError::InvalidInput { .. }), "{err}");
let message = err.to_string();
assert!(
message.contains("is not valid JSON"),
"stable corruption prefix missing: {message}"
);
}
#[cfg(unix)]
#[test]
fn manifest_writes_0640() {
use std::os::unix::fs::PermissionsExt;
let root = tempfile::tempdir().expect("tempdir");
write_manifest(root.path(), &sample()).expect("writes");
let mode = root
.path()
.join(WORKSPACE_MANIFEST_NAME)
.metadata()
.expect("meta")
.permissions()
.mode();
assert_eq!(mode & 0o777, 0o640, "manifest mode is 0640");
}
}
#[cfg(test)]
mod checkout_tests {
use super::*;
#[test]
fn unix_ms_formats_rfc3339_utc() {
assert_eq!(unix_ms_to_rfc3339_utc(0), "1970-01-01T00:00:00.000Z");
assert_eq!(
unix_ms_to_rfc3339_utc(1_000_000_000_000),
"2001-09-09T01:46:40.000Z"
);
assert_eq!(
unix_ms_to_rfc3339_utc(1_234_567_890_123),
"2009-02-13T23:31:30.123Z"
);
assert_eq!(
unix_ms_to_rfc3339_utc(1_709_208_000_000),
"2024-02-29T12:00:00.000Z"
);
}
#[test]
fn now_utc_is_well_formed() {
let stamp = rfc3339_now_utc();
assert!(stamp.ends_with('Z'), "{stamp}");
assert_eq!(stamp.len(), 24, "YYYY-MM-DDTHH:MM:SS.mmmZ: {stamp}");
assert!(stamp.starts_with("20"), "{stamp}");
}
#[test]
fn gitignore_is_idempotent_and_append_safe() {
let root = tempfile::tempdir().expect("tempdir");
write_gitignore(root.path()).expect("writes");
let first = std::fs::read_to_string(root.path().join(".gitignore")).expect("read");
assert!(first.contains("scripts-manifest.json") && first.contains("*.py"));
write_gitignore(root.path()).expect("rewrites");
let second = std::fs::read_to_string(root.path().join(".gitignore")).expect("read");
assert_eq!(first, second, "second write is a no-op");
let root2 = tempfile::tempdir().expect("tempdir");
std::fs::write(root2.path().join(".gitignore"), "target/\n").expect("seed");
write_gitignore(root2.path()).expect("appends");
let merged = std::fs::read_to_string(root2.path().join(".gitignore")).expect("read");
assert!(
merged.starts_with("target/"),
"user content first: {merged:?}"
);
assert!(
merged.contains("*.py"),
"codec pattern appended: {merged:?}"
);
}
#[test]
fn recheckout_gate_refuses_unrelated_non_empty_targets() {
let root = tempfile::tempdir().expect("tempdir");
let target = root.path().join("ws");
assert!(ensure_recheckout_safe(&target, "p").is_ok());
std::fs::create_dir_all(&target).expect("mkdir");
assert!(ensure_recheckout_safe(&target, "p").is_ok());
std::fs::write(target.join("unrelated.txt"), b"x").expect("seed");
let err = ensure_recheckout_safe(&target, "p").expect_err("refuses");
let message = err.to_string();
assert!(
message.contains("not empty and is not an ign workspace"),
"{message}"
);
assert!(message.contains("ws"), "names the dir: {message}");
std::fs::write(target.join(WORKSPACE_MANIFEST_NAME), b"{bad").expect("seed");
let err = ensure_recheckout_safe(&target, "p").expect_err("refuses");
assert!(
err.to_string()
.contains("not empty and is not an ign workspace")
);
let mut manifest = WorkspaceManifest {
schema_version: WORKSPACE_MANIFEST_SCHEMA_VERSION,
project: "other".to_string(),
profile: "rig".to_string(),
checked_out_at: "2026-09-15T00:00:00.000Z".to_string(),
members: BTreeMap::new(),
};
write_manifest(&target, &manifest).expect("writes");
let err = ensure_recheckout_safe(&target, "p").expect_err("refuses");
let message = err.to_string();
assert!(
message.contains("\"other\"") && message.contains("\"p\""),
"{message}"
);
manifest.project = "p".to_string();
write_manifest(&target, &manifest).expect("rewrites");
assert!(ensure_recheckout_safe(&target, "p").is_ok());
}
}