use std::collections::HashSet;
use std::path::Path;
use std::process::Command;
use super::types::*;
pub(crate) const MAX_SLUG_LEN: usize = 54;
pub(crate) fn slugify(description: &str) -> String {
slugify_with_max(description, MAX_SLUG_LEN)
}
fn slugify_with_max(description: &str, max_len: usize) -> String {
let slug: String = description
.to_lowercase()
.chars()
.map(|c| if c.is_alphanumeric() { c } else { '-' })
.collect();
let mut result = String::new();
let mut prev_hyphen = false;
for c in slug.chars() {
if c == '-' {
if !prev_hyphen && !result.is_empty() {
result.push('-');
}
prev_hyphen = true;
} else {
result.push(c);
prev_hyphen = false;
}
}
let trimmed = result.trim_end_matches('-');
if trimmed.len() > max_len {
trimmed[..max_len].rfind('-').map_or_else(
|| trimmed[..max_len].to_string(),
|pos| trimmed[..pos].to_string(),
)
} else {
trimmed.to_string()
}
}
pub(super) fn parse_criterion_id(text: &str) -> (String, String) {
let trimmed = text.trim();
let upper = trimmed.to_uppercase();
if let Some(rest) = upper.strip_prefix("AC-") {
if let Some(colon_pos) = rest.find(':') {
let digits = &rest[..colon_pos];
if !digits.is_empty() && digits.chars().all(|c| c.is_ascii_digit()) {
let id = format!("AC-{digits}");
let remaining = trimmed[3 + colon_pos + 1..].trim().to_string();
return (id, remaining);
}
}
}
(String::new(), trimmed.to_string())
}
pub(crate) fn extract_criteria(
doc: &super::super::design_doc::DesignDoc,
source_filename: &str,
) -> CriteriaFile {
let explicit_ids: HashSet<String> = doc
.acceptance_criteria
.iter()
.filter_map(|raw| {
let (id, _) = parse_criterion_id(raw);
if id.is_empty() {
None
} else {
Some(id)
}
})
.collect();
let mut auto_counter = 0u32;
let mut criteria = Vec::new();
for raw in &doc.acceptance_criteria {
let (parsed_id, text) = parse_criterion_id(raw);
let id = if parsed_id.is_empty() {
loop {
auto_counter += 1;
let candidate = format!("AC-{auto_counter}");
if !explicit_ids.contains(&candidate) {
break candidate;
}
}
} else {
parsed_id
};
criteria.push(Criterion {
id,
text,
criterion_type: "functional".to_string(),
});
}
CriteriaFile {
source_doc: source_filename.to_string(),
extracted_at: chrono::Utc::now().to_rfc3339(),
criteria,
}
}
pub(crate) fn detect_conventions(repo_root: &Path) -> ProjectConventions {
let mut conv = ProjectConventions {
test_command: None,
lint_commands: Vec::new(),
allowed_tools: Vec::new(),
};
if repo_root.join("Cargo.toml").is_file() || repo_root.join("crosslink/Cargo.toml").is_file() {
conv.test_command = Some("cargo test".to_string());
conv.lint_commands
.push("cargo clippy -- -D warnings".to_string());
conv.lint_commands.push("cargo fmt --check".to_string());
conv.allowed_tools.push("Bash(cargo *)".to_string());
}
if repo_root.join("package.json").is_file() {
if conv.test_command.is_none() {
conv.test_command = Some("npm test".to_string());
}
conv.allowed_tools.push("Bash(npm *)".to_string());
conv.allowed_tools.push("Bash(npx *)".to_string());
}
if repo_root.join("pyproject.toml").is_file() || repo_root.join("requirements.txt").is_file() {
if conv.test_command.is_none() {
conv.test_command = Some("uv run pytest".to_string());
}
conv.lint_commands.push("ruff check .".to_string());
conv.allowed_tools.push("Bash(uv *)".to_string());
conv.allowed_tools.push("Bash(python3 *)".to_string());
}
if repo_root.join("go.mod").is_file() {
if conv.test_command.is_none() {
conv.test_command = Some("go test ./...".to_string());
}
conv.lint_commands.push("go vet ./...".to_string());
conv.allowed_tools.push("Bash(go *)".to_string());
}
if repo_root.join("justfile").is_file() || repo_root.join("Justfile").is_file() {
conv.allowed_tools.push("Bash(just *)".to_string());
}
if repo_root.join("Makefile").is_file() || repo_root.join("makefile").is_file() {
conv.allowed_tools.push("Bash(make *)".to_string());
}
let has_shell = repo_root.join(".shellcheckrc").is_file()
|| ["", "scripts", "bin"].iter().any(|sub| {
let dir = if sub.is_empty() {
repo_root.to_path_buf()
} else {
repo_root.join(sub)
};
dir.is_dir()
&& std::fs::read_dir(&dir).ok().is_some_and(|entries| {
entries.filter_map(std::result::Result::ok).any(|e| {
let n = e.file_name().to_string_lossy().to_string();
std::path::Path::new(&n).extension().is_some_and(|ext| {
ext.eq_ignore_ascii_case("sh") || ext.eq_ignore_ascii_case("bash")
})
})
})
});
if has_shell {
conv.lint_commands.push("shellcheck **/*.sh".to_string());
conv.allowed_tools.push("Bash(shellcheck *)".to_string());
conv.allowed_tools.push("Bash(bash *)".to_string());
conv.allowed_tools.push("Bash(bats *)".to_string());
}
if repo_root.join("mix.exs").is_file() {
if conv.test_command.is_none() {
conv.test_command = Some("mix test".to_string());
}
conv.lint_commands
.push("mix format --check-formatted".to_string());
conv.allowed_tools.push("Bash(mix compile *)".to_string());
conv.allowed_tools.push("Bash(mix test *)".to_string());
conv.allowed_tools.push("Bash(mix format *)".to_string());
conv.allowed_tools.push("Bash(mix deps.get *)".to_string());
conv.allowed_tools.push("Bash(mix deps.tree *)".to_string());
conv.allowed_tools
.push("Bash(mix deps.compile *)".to_string());
conv.allowed_tools
.push("Bash(mix ecto.migrate *)".to_string());
conv.allowed_tools
.push("Bash(mix gettext.extract *)".to_string());
conv.allowed_tools
.push("Bash(mix gettext.merge *)".to_string());
conv.allowed_tools.push("Bash(mix help *)".to_string());
conv.allowed_tools.push("Bash(mix hex.info *)".to_string());
conv.allowed_tools.push("Bash(mix xref *)".to_string());
conv.allowed_tools
.push("Bash(mix phx.routes *)".to_string());
conv.allowed_tools.push("Bash(mix dialyzer *)".to_string());
if let Ok(content) = std::fs::read_to_string(repo_root.join("mix.exs")) {
if content.contains(":credo") {
conv.lint_commands.push("mix credo --strict".to_string());
conv.allowed_tools.push("Bash(mix credo *)".to_string());
}
if content.contains(":sobelow") {
conv.lint_commands.push("mix sobelow --config".to_string());
conv.allowed_tools.push("Bash(mix sobelow *)".to_string());
}
if content.contains(":tidewave") {
conv.allowed_tools
.push("mcp__tidewave__get_logs".to_string());
conv.allowed_tools
.push("mcp__tidewave__get_source_location".to_string());
conv.allowed_tools
.push("mcp__tidewave__get_docs".to_string());
conv.allowed_tools
.push("mcp__tidewave__get_ecto_schemas".to_string());
conv.allowed_tools
.push("mcp__tidewave__search_package_docs".to_string());
conv.allowed_tools
.push("mcp__tidewave__list_project_files".to_string());
conv.allowed_tools
.push("mcp__tidewave__read_project_file".to_string());
conv.allowed_tools
.push("mcp__tidewave__grep_project_files".to_string());
conv.allowed_tools
.push("mcp__tidewave__execute_sql_query".to_string());
conv.allowed_tools
.push("mcp__tidewave__project_eval".to_string());
}
}
}
conv
}
pub(crate) const fn verify_level_name(level: &VerifyLevel) -> &'static str {
match level {
VerifyLevel::Local => "local",
VerifyLevel::Ci => "ci",
VerifyLevel::Thorough => "thorough",
}
}
pub(crate) fn validate_kickoff_report(report: &KickoffReport) -> Vec<String> {
let mut warnings = Vec::new();
if report.schema_version.is_none() {
warnings.push("Missing schema_version field".to_string());
}
if report.agent_id.is_none() {
warnings.push("Missing agent_id field".to_string());
}
if report.issue_id.is_none() {
warnings.push("Missing issue_id field".to_string());
}
if report.criteria.is_empty() {
warnings.push("No criteria results in report".to_string());
}
warnings
}
pub(crate) const KICKOFF_EXCLUDE_PATTERNS: &[&str] = &[
"KICKOFF.md",
".kickoff-status",
".kickoff-slug",
".kickoff-metadata.json",
"PLAN_KICKOFF.md",
".kickoff-plan.json",
".kickoff-criteria.json",
".kickoff-report.json",
];
pub(crate) fn missing_exclude_patterns(existing_content: &str) -> Vec<&'static str> {
KICKOFF_EXCLUDE_PATTERNS
.iter()
.filter(|pattern| !existing_content.lines().any(|l| l.trim() == **pattern))
.copied()
.collect()
}
pub(crate) fn tmux_session_name(name: &str) -> String {
let sanitized: String = name
.chars()
.map(|c| if c == '.' || c == ':' { '-' } else { c })
.collect();
if sanitized.len() > 64 {
sanitized[..64].to_string()
} else {
sanitized
}
}
pub(super) fn tmux_session_exists(name: &str) -> bool {
Command::new("tmux")
.args(["has-session", "-t", name])
.output()
.is_ok_and(|o| o.status.success())
}
pub(crate) fn command_available(cmd: &str) -> bool {
#[cfg(target_os = "windows")]
let lookup = Command::new("where.exe").arg(cmd).output();
#[cfg(not(target_os = "windows"))]
let lookup = Command::new("which").arg(cmd).output();
lookup.is_ok_and(|o| o.status.success())
}
pub(super) fn detect_platform() -> Platform {
if cfg!(target_os = "macos") {
Platform::MacOS
} else if cfg!(target_os = "windows") {
Platform::Windows
} else {
Platform::Linux(detect_linux_distro())
}
}
pub(super) fn detect_linux_distro() -> LinuxDistro {
let content = match std::fs::read_to_string("/etc/os-release") {
Ok(c) => c.to_lowercase(),
Err(_) => return LinuxDistro::Other,
};
if content.contains("id=debian")
|| content.contains("id=ubuntu")
|| content.contains("id_like=debian")
|| content.contains("id_like=\"debian")
{
LinuxDistro::Debian
} else if content.contains("id=fedora")
|| content.contains("id=rhel")
|| content.contains("id=centos")
|| content.contains("id_like=fedora")
|| content.contains("id_like=\"fedora")
|| content.contains("id_like=\"rhel")
{
LinuxDistro::Fedora
} else if content.contains("id=arch")
|| content.contains("id_like=arch")
|| content.contains("id_like=\"arch")
{
LinuxDistro::Arch
} else if content.contains("id=alpine") {
LinuxDistro::Alpine
} else {
LinuxDistro::Other
}
}
pub(super) fn install_hint(cmd: &str, platform: &Platform) -> String {
match cmd {
"timeout" | "gtimeout" => match platform {
Platform::MacOS => "On macOS, install GNU coreutils:\n\
\n brew install coreutils\n\
\nThis provides `gtimeout` which crosslink will use automatically."
.to_string(),
Platform::Linux(LinuxDistro::Debian) => {
"Install coreutils (provides `timeout`):\n\n sudo apt install coreutils"
.to_string()
}
Platform::Linux(LinuxDistro::Fedora) => {
"Install coreutils (provides `timeout`):\n\n sudo dnf install coreutils"
.to_string()
}
Platform::Linux(LinuxDistro::Arch) => {
"Install coreutils (provides `timeout`):\n\n sudo pacman -S coreutils".to_string()
}
Platform::Linux(LinuxDistro::Alpine) => {
"Install coreutils (provides `timeout`):\n\n apk add coreutils".to_string()
}
Platform::Linux(LinuxDistro::Other) => {
"Install GNU coreutils to get the `timeout` command.\n\
Use your distribution's package manager (e.g. apt, dnf, pacman)."
.to_string()
}
Platform::Windows => "Install GNU coreutils via scoop or chocolatey:\n\
\n scoop install coreutils\n choco install gnuwin32-coreutils.install"
.to_string(),
},
"tmux" => match platform {
Platform::MacOS => "`tmux` is not installed.\n\n brew install tmux\n\
\nAlternatively, use --container docker to avoid tmux."
.to_string(),
Platform::Linux(LinuxDistro::Debian) => {
"`tmux` is not installed.\n\n sudo apt install tmux\n\
\nAlternatively, use --container docker to avoid tmux."
.to_string()
}
Platform::Linux(LinuxDistro::Fedora) => {
"`tmux` is not installed.\n\n sudo dnf install tmux\n\
\nAlternatively, use --container docker to avoid tmux."
.to_string()
}
Platform::Linux(LinuxDistro::Arch) => {
"`tmux` is not installed.\n\n sudo pacman -S tmux\n\
\nAlternatively, use --container docker to avoid tmux."
.to_string()
}
Platform::Linux(LinuxDistro::Alpine) => "`tmux` is not installed.\n\n apk add tmux\n\
\nAlternatively, use --container docker to avoid tmux."
.to_string(),
Platform::Linux(LinuxDistro::Other) => "`tmux` is not installed.\n\
Install with your distribution's package manager (e.g. apt, dnf, pacman).\n\
\nAlternatively, use --container docker to avoid tmux."
.to_string(),
Platform::Windows => "`tmux` is not available on Windows.\n\
Use --container docker instead for containerized agent mode."
.to_string(),
},
"claude" => match platform {
Platform::MacOS => "`claude` CLI is not installed.\n\n brew install claude-code\n\
\nOr install via npm:\n\n npm install -g @anthropic-ai/claude-code"
.to_string(),
Platform::Windows | Platform::Linux(_) => {
"`claude` CLI is not installed.\n\n npm install -g @anthropic-ai/claude-code"
.to_string()
}
},
"gh" => match platform {
Platform::MacOS => {
"`gh` (GitHub CLI) is required for --verify ci/thorough.\n\n brew install gh"
.to_string()
}
Platform::Linux(LinuxDistro::Debian) => {
"`gh` (GitHub CLI) is required for --verify ci/thorough.\n\
\nInstall via apt (official repo):\n\
\n sudo mkdir -p /etc/apt/keyrings\n \
curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg \
| sudo tee /etc/apt/keyrings/githubcli-archive-keyring.gpg > /dev/null\n \
echo \"deb [arch=$(dpkg --print-architecture) \
signed-by=/etc/apt/keyrings/githubcli-archive-keyring.gpg] \
https://cli.github.com/packages stable main\" \
| sudo tee /etc/apt/sources.list.d/github-cli.list > /dev/null\n \
sudo apt update && sudo apt install gh\n\
\nOr install a single binary from: https://cli.github.com"
.to_string()
}
Platform::Linux(LinuxDistro::Fedora) => {
"`gh` (GitHub CLI) is required for --verify ci/thorough.\
\n\n sudo dnf install gh"
.to_string()
}
Platform::Linux(LinuxDistro::Arch) => {
"`gh` (GitHub CLI) is required for --verify ci/thorough.\
\n\n sudo pacman -S github-cli"
.to_string()
}
Platform::Linux(LinuxDistro::Alpine) => {
"`gh` (GitHub CLI) is required for --verify ci/thorough.\
\n\n apk add github-cli"
.to_string()
}
Platform::Linux(LinuxDistro::Other) => {
"`gh` (GitHub CLI) is required for --verify ci/thorough.\n\
Install from: https://cli.github.com"
.to_string()
}
Platform::Windows => "`gh` (GitHub CLI) is required for --verify ci/thorough.\
\n\n winget install GitHub.cli\n\
\nOr: scoop install gh"
.to_string(),
},
"docker" => match platform {
Platform::MacOS => "`docker` is not installed.\n\n brew install --cask docker\n\
\nOr install Docker Desktop from: https://docs.docker.com/get-docker/\n\
\nAlternatively, use --container none for local mode."
.to_string(),
Platform::Linux(LinuxDistro::Debian) => "`docker` is not installed.\n\
\nInstall Docker Engine:\n\
\n curl -fsSL https://get.docker.com | sh\n sudo usermod -aG docker $USER\n\
\nOr see: https://docs.docker.com/engine/install/\n\
\nAlternatively, use --container none for local mode."
.to_string(),
Platform::Linux(LinuxDistro::Fedora) => "`docker` is not installed.\n\
\n sudo dnf install docker-ce docker-ce-cli containerd.io\n\
\nOr: curl -fsSL https://get.docker.com | sh\n\
\nAlternatively, use --container none for local mode."
.to_string(),
Platform::Linux(LinuxDistro::Arch) => "`docker` is not installed.\n\
\n sudo pacman -S docker\n sudo systemctl enable --now docker\n\
\nAlternatively, use --container none for local mode."
.to_string(),
Platform::Linux(LinuxDistro::Alpine) => "`docker` is not installed.\n\
\n apk add docker\n rc-update add docker default\n service docker start\n\
\nAlternatively, use --container none for local mode."
.to_string(),
Platform::Linux(LinuxDistro::Other) | Platform::Windows => {
"`docker` is not installed.\n\
Install from: https://docs.docker.com/get-docker/\n\
\nAlternatively, use --container none for local mode."
.to_string()
}
},
"podman" => match platform {
Platform::MacOS => "`podman` is not installed.\n\n brew install podman\n\
\nAlternatively, use --container none for local mode."
.to_string(),
Platform::Linux(LinuxDistro::Debian) => {
"`podman` is not installed.\n\n sudo apt install podman\n\
\nAlternatively, use --container none for local mode."
.to_string()
}
Platform::Linux(LinuxDistro::Fedora) => {
"`podman` is not installed.\n\n sudo dnf install podman\n\
\nAlternatively, use --container none for local mode."
.to_string()
}
Platform::Linux(LinuxDistro::Arch) => {
"`podman` is not installed.\n\n sudo pacman -S podman\n\
\nAlternatively, use --container none for local mode."
.to_string()
}
Platform::Linux(LinuxDistro::Alpine) => {
"`podman` is not installed.\n\n apk add podman\n\
\nAlternatively, use --container none for local mode."
.to_string()
}
Platform::Linux(LinuxDistro::Other) => "`podman` is not installed.\n\
Install from: https://podman.io/getting-started/installation\n\
\nAlternatively, use --container none for local mode."
.to_string(),
Platform::Windows => "`podman` is not installed.\n\
\n winget install RedHat.Podman\n\
\nAlternatively, use --container none for local mode."
.to_string(),
},
other => {
format!("`{other}` is not installed. Install it using your system package manager.")
}
}
}
pub(crate) fn format_duration(secs: u64) -> String {
if secs >= 3600 {
let h = secs / 3600;
let m = (secs % 3600) / 60;
if m > 0 {
format!("{h}h {m}m")
} else {
format!("{h}h")
}
} else if secs >= 60 {
let m = secs / 60;
let s = secs % 60;
if s > 0 {
format!("{m}m {s}s")
} else {
format!("{m}m")
}
} else {
format!("{secs}s")
}
}
pub(super) fn truncate_str(s: &str, max: usize) -> String {
if s.chars().count() > max {
s.chars().take(max).collect()
} else {
s.to_string()
}
}
pub(super) fn normalize_status(raw: &str) -> String {
let lower = raw.to_lowercase();
if lower == "done" {
"done".to_string()
} else if lower.contains("fail") || lower.contains("error") {
"failed".to_string()
} else if lower.contains("running") || raw.is_empty() {
"running".to_string()
} else {
raw.to_string()
}
}
pub(super) fn read_timeout_metadata(wt_path: &Path) -> Option<KickoffMetadata> {
let meta_path = wt_path.join(".kickoff-metadata.json");
let content = std::fs::read_to_string(&meta_path).ok()?;
serde_json::from_str(&content).ok()
}
pub(super) fn read_agent_id(wt_path: &Path, _crosslink_dir: &Path) -> Option<String> {
let agent_json = wt_path.join(".crosslink").join("agent.json");
if agent_json.exists() {
if let Ok(content) = std::fs::read_to_string(&agent_json) {
if let Ok(val) = serde_json::from_str::<serde_json::Value>(&content) {
return val
.get("agent_id")
.and_then(|v| v.as_str())
.map(String::from);
}
}
}
None
}
pub(super) fn read_agent_issue(wt_path: &Path, _crosslink_dir: &Path) -> Option<String> {
let criteria_path = wt_path.join(".kickoff-criteria.json");
if criteria_path.exists() {
if let Ok(content) = std::fs::read_to_string(&criteria_path) {
if let Ok(val) = serde_json::from_str::<serde_json::Value>(&content) {
if let Some(id) = val.get("issue_id").and_then(serde_json::Value::as_i64) {
return Some(crate::utils::format_issue_id(id));
}
}
}
}
let agent_json = wt_path.join(".crosslink").join("agent.json");
if agent_json.exists() {
if let Ok(content) = std::fs::read_to_string(&agent_json) {
if let Ok(val) = serde_json::from_str::<serde_json::Value>(&content) {
if let Some(id) = val.get("issue_id").and_then(serde_json::Value::as_i64) {
return Some(crate::utils::format_issue_id(id));
}
}
}
}
None
}
pub(super) fn rand_suffix() -> u32 {
use std::time::SystemTime;
let seed = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.unwrap_or_default()
.subsec_nanos();
seed % 10000
}
pub(super) fn rand_hex_suffix() -> String {
use std::time::SystemTime;
let nanos = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.unwrap_or_default()
.subsec_nanos();
let pid = std::process::id();
let mixed = nanos.wrapping_mul(31).wrapping_add(pid);
format!("{:04x}", mixed & 0xFFFF)
}
pub(super) fn classify_agent(agent: &AgentInfo) -> CleanupClass {
match agent.status.as_str() {
"done" | "failed" => CleanupClass::Done,
"running" => CleanupClass::Active,
_ => CleanupClass::Stale,
}
}