use std::path::Path;
use std::process::Command;
use loopflow::engine::wave_context::{resolve_managed_wave, WaveResolveError};
use loopflow::id::WaveId;
use loopflow::store::sqlite::SqliteStore;
use loopflow::store::{open_store, PmSnapshotRow, StorageConfig};
use loopflow::wave::Wave;
#[tokio::test]
async fn resolver_matrix_agrees_across_every_environment() {
let tmp = tempfile::tempdir().expect("tempdir");
let store = open_store(&StorageConfig::sqlite(tmp.path().join("loopflow.db")))
.await
.expect("open store");
let repo = tmp.path().join("repo");
std::fs::create_dir_all(&repo).expect("repo");
let wave = Wave::new(
WaveId::new(),
"product".to_string(),
std::fs::canonicalize(&repo)
.expect("canonical repo")
.display()
.to_string(),
);
store.create_wave(&wave).await.expect("register wave");
let uuid = wave.id().as_str();
assert_eq!(
resolve_managed_wave(Some(&store), Some(&repo), None, Some(uuid))
.await
.expect("uuid resolves")
.id(),
wave.id()
);
assert_eq!(
resolve_managed_wave(Some(&store), Some(&repo), None, Some("product"))
.await
.expect("hand-set name resolves")
.id(),
wave.id()
);
assert_eq!(
resolve_managed_wave(
Some(&store),
Some(&repo),
Some("product"),
Some("something-else"),
)
.await
.expect("explicit wins")
.id(),
wave.id()
);
let stale = WaveId::new();
assert!(matches!(
resolve_managed_wave(Some(&store), Some(&repo), None, Some(stale.as_str())).await,
Err(WaveResolveError::StaleIdentity(value)) if value == stale.as_str()
));
assert!(matches!(
resolve_managed_wave(Some(&store), Some(&repo), None, None).await,
Err(WaveResolveError::NoContext)
));
assert!(matches!(
resolve_managed_wave(Some(&store), Some(&repo), None, Some(" ")).await,
Err(WaveResolveError::NoContext)
));
assert!(matches!(
resolve_managed_wave(Some(&store), Some(&repo), Some(" "), None).await,
Err(WaveResolveError::EmptyExplicit)
));
assert!(matches!(
resolve_managed_wave(
Some(&store),
Some(&repo),
Some("definitely-unknown"),
None,
)
.await,
Err(WaveResolveError::UnknownExplicit(value)) if value == "definitely-unknown"
));
assert!(matches!(
resolve_managed_wave(None, Some(&repo), Some("product"), None).await,
Err(WaveResolveError::Registry(_))
));
assert!(matches!(
resolve_managed_wave(Some(&store), Some(&repo), None, Some("ghost")).await,
Err(WaveResolveError::StaleIdentity(value)) if value == "ghost"
));
let other_repo = tmp.path().join("other-repo");
std::fs::create_dir_all(&other_repo).expect("other repo");
let other = Wave::new(
WaveId::new(),
"product".to_string(),
std::fs::canonicalize(&other_repo)
.expect("canonical other repo")
.display()
.to_string(),
);
store
.create_wave(&other)
.await
.expect("register other Wave");
assert!(matches!(
resolve_managed_wave(Some(&store), None, Some("product"), None).await,
Err(WaveResolveError::AmbiguousWave { .. })
));
assert!(matches!(
resolve_managed_wave(Some(&store), Some(&repo), None, Some(other.id().as_str())).await,
Err(WaveResolveError::RepositoryMismatch { .. })
));
}
fn seed(home: &Path, repo: &Path, wave_name: &str) -> Wave {
std::fs::create_dir_all(home).expect("home");
std::fs::create_dir_all(repo).expect("repo");
let store = SqliteStore::new(&home.join("loopflow.db")).expect("open store");
let wave = Wave::new(
WaveId::new(),
wave_name.to_string(),
repo.display().to_string(),
);
store.create_wave(&wave).expect("register wave");
store
.put_pm_snapshot(&PmSnapshotRow {
wave_id: wave.id().clone(),
provider: "linear".to_string(),
initiative: "initiative-1".to_string(),
synced_at: chrono::Utc::now().timestamp(),
payload: r#"{"projects":[],"items":[]}"#.to_string(),
})
.expect("seed pm snapshot");
wave
}
fn lf(home: &Path, repo: &Path, args: &[&str], wave_id: Option<&str>) -> std::process::Output {
let mut command = Command::new(env!("CARGO_BIN_EXE_lf"));
command
.args(args)
.current_dir(repo)
.env("LF_HOME", home)
.env_remove("LF_DB_PATH")
.env_remove("LF_CONTROL_HOME")
.env_remove("LF_CONTROL_DB_PATH")
.env_remove("LF_TRACE_ID")
.env_remove("LF_WAVE_ID");
if let Some(id) = wave_id {
command.env("LF_WAVE_ID", id);
}
command.output().expect("lf runs")
}
fn wave_field(output: &std::process::Output) -> String {
assert!(
output.status.success(),
"command failed: {}",
String::from_utf8_lossy(&output.stderr)
);
let json: serde_json::Value = serde_json::from_slice(&output.stdout).expect("stdout is JSON");
json.get("wave")
.and_then(|wave| wave.get("name").or(Some(wave)))
.and_then(serde_json::Value::as_str)
.expect("a wave name")
.to_string()
}
#[test]
fn pm_show_and_status_agree_from_a_resident_uuid() {
let tmp = tempfile::tempdir().expect("tempdir");
let home = tmp.path().join("home");
let repo = tmp.path().join("repo");
let wave = seed(&home, &repo, "product");
let uuid = wave.id().as_str();
let pm = lf(
&home,
&repo,
&["pm", "show", "--no-sync", "--json"],
Some(uuid),
);
let status = lf(&home, &repo, &["status", "--json"], Some(uuid));
assert_eq!(wave_field(&pm), "product");
assert_eq!(wave_field(&status), "product");
}
#[test]
fn pm_show_honors_the_shared_resolution_rules() {
let tmp = tempfile::tempdir().expect("tempdir");
let home = tmp.path().join("home");
let repo = tmp.path().join("repo");
let wave = seed(&home, &repo, "product");
let uuid = wave.id().as_str();
let overridden = lf(
&home,
&repo,
&["pm", "show", "--wave", "product", "--no-sync", "--json"],
Some(&WaveId::new().to_string()),
);
assert_eq!(wave_field(&overridden), "product");
let named = lf(
&home,
&repo,
&["pm", "show", "--no-sync", "--json"],
Some("product"),
);
assert_eq!(wave_field(&named), "product");
let missing = lf(&home, &repo, &["pm", "show", "--no-sync", "--json"], None);
assert!(!missing.status.success());
assert!(
String::from_utf8_lossy(&missing.stderr).contains("determine wave"),
"missing-context stderr: {}",
String::from_utf8_lossy(&missing.stderr)
);
let stale_id = WaveId::new().to_string();
let stale = lf(
&home,
&repo,
&["pm", "show", "--no-sync", "--json"],
Some(&stale_id),
);
assert!(!stale.status.success());
let stale_err = String::from_utf8_lossy(&stale.stderr);
assert!(
stale_err.contains("stale") && stale_err.contains(&stale_id),
"stale stderr: {stale_err}"
);
let _ = uuid;
}
#[test]
fn unknown_explicit_wave_is_rejected_identically_by_every_consumer() {
let tmp = tempfile::tempdir().expect("tempdir");
let home = tmp.path().join("home");
let repo = tmp.path().join("repo");
let wave = seed(&home, &repo, "product");
let uuid = wave.id().as_str();
let assert_rejected = |output: std::process::Output, label: &str| {
assert!(
!output.status.success(),
"{label}: unknown explicit wave should exit non-zero"
);
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("definitely-unknown"),
"{label}: stderr should name the unknown wave: {stderr}"
);
assert!(
stderr.contains("is not registered on this machine"),
"{label}: stderr should classify as unregistered: {stderr}"
);
};
assert_rejected(
lf(
&home,
&repo,
&[
"chat",
"--history",
"--json",
"--wave",
"definitely-unknown",
],
None,
),
"chat history (no ambient)",
);
assert_rejected(
lf(
&home,
&repo,
&["status", "--wave", "definitely-unknown"],
None,
),
"status (no ambient)",
);
assert_rejected(
lf(
&home,
&repo,
&["pm", "show", "--wave", "definitely-unknown", "--no-sync"],
None,
),
"pm show (no ambient)",
);
assert_rejected(
lf(
&home,
&repo,
&[
"chat",
"--history",
"--json",
"--wave",
"definitely-unknown",
],
Some(uuid),
),
"chat history (with ambient)",
);
assert_rejected(
lf(
&home,
&repo,
&["status", "--wave", "definitely-unknown"],
Some(uuid),
),
"status (with ambient)",
);
assert_rejected(
lf(
&home,
&repo,
&["pm", "show", "--wave", "definitely-unknown", "--no-sync"],
Some(uuid),
),
"pm show (with ambient)",
);
}