M0: compilable skeleton — Kigi 0.1.0 fork surgery

Hard fork of xai-org/grok-build (Apache-2.0) re-targeted as Kigi, an
unofficial Kimi Code CLI community build.

Rename & identity
- 72 xai-*/xai-grok-* crates -> kigi-* (explicit: xai-grok-pager-bin ->
  kigi-bin [binary `kigi`], xai-grok-pager -> kigi-tui; rest mechanical);
  ptyctl, ptyctl-cli, third_party/ unchanged; proto package
  xai.grok.tools.v1 -> kigi.tools.v1
- Config home ~/.kigi (KIGI_SHARE_DIR override), env prefix GROK_* ->
  KIGI_*, `kigi --version` carries the unofficial-community-build notice
- clap identity, help text, startup banner, prompt templates rebranded
  (templates re-encrypted)

Deletions (PRD removal list #5/#6/#7/#9/#10)
- voice input (xai-grok-voice) and all TUI wiring
- telemetry: Mixpanel client, external OTel stream, Sentry, OTLP layers,
  trace/GCS/S3 upload queues (kigi-file-utils halved), workspace upload
  module & dc_log, heap-profile uploader, auth-diagnostics uploader,
  session-analytics halves of feedback; local zero-egress observability
  preserved in new kigi-log crate (unified log, --debug firehose,
  subsystem file logs, opt-in instrumentation)
- announcements (crate, remote-settings fields, TUI surfaces)
- plugin marketplace (crate, sources/browse/CTA/extensions-modal tab);
  direct plugin install/uninstall/update via kigi-agent git_install kept
- relay/gateway/assets endpoints and features (agent relay, headless
  relay transport, gateway bridge, LeaderEnvUrls); leader IPC socket now
  ~/.kigi/leader.sock + KIGI_LEADER_SOCKET, no ws-url derivation
- functional types rehomed instead of deleted: PermissionMode ->
  kigi-config-types, McpInitStrategy -> kigi-mcp, PrCreationSource ->
  session signals, TerminalDiagnostics -> kigi-pager-render, agent_id ->
  shell util

Endpoints
- kigi-env rewritten: single production KigiEndpoints {coding_api_base_url
  https://api.kimi.com/coding/v1 (KIGI_CODE_BASE_URL), oauth_host
  https://auth.kimi.com (KIGI_OAUTH_HOST), update_base_url (GitHub
  Releases API), upgrade_page_url}; GrokBuildEnvironment enum deleted

Toolchain & workspace hygiene
- Rust 1.97.0 pinned; edition 2024; full cargo update; git2 hoisted to
  workspace at 0.21 (Option->Result API migration), quick-xml 0.41
- Root Cargo.toml hand-maintained (PRD §8.1): version 0.1.0 inherited by
  all members, members sorted, unused deps pruned
- cargo-deny advisories gate (deny.toml with documented transitive
  exceptions); CI workflow (check/clippy/fmt/deny/test, macOS+Linux)
- cross-crate test seams re-gated behind `test-support` cargo feature;
  insta snapshot baselines renamed to the kigi_tui prefix
- clippy --workspace --all-targets: zero warnings; fmt clean

Fixes surfaced by the port
- updater probe/installer divergence (bin/kigi vs bin/grok symlink set)
- idle model-metadata refresh dead under KIGI_CODE_BASE_URL override
  (new is_effective_coding_endpoint_url, loopback+override aware)
- macOS symlinked-TMPDIR fixture canonicalization (foreign_sessions,
  fast-worktree); RSS measurement tests serialized via serial_test

Docs & legal (Apache §4)
- NOTICE added (upstream attribution + change statement); THIRD-PARTY
  notices sustained; kigi-tools ported-code notices extended; README,
  CONTRIBUTING, SECURITY, AGENTS.md rewritten

Out of scope for M0 (tracked): Kimi auth/inference (M1), search/fetch,
command parity, config import (M2), Computer Hub excision & final
brand-token sweep (M2), distribution & self-update rewrite (M3).
This commit is contained in:
2026-07-17 05:31:01 -04:00
commit d6c20fc13f
2612 changed files with 1353757 additions and 0 deletions
@@ -0,0 +1,604 @@
//! AGENTS.md / Claude.md / rules directory discovery and loading.
//!
//! Searches from cwd to repo root, plus `~/.kigi/`. Also discovers
//! `*.md` files in `.kigi/rules/` and `.claude/rules/` directories.
use std::path::{Path, PathBuf};
use crate::prompt::ignore::{build_gitignore, is_ignored};
use kigi_tools::types::compat::CompatConfig;
/// Represents an agent config file with its path and content.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct AgentConfigFile {
/// The filename (e.g., "AGENTS.md", "Claude.md")
pub file_name: String,
/// The full absolute path to the config file
pub file_path: String,
/// The content of the config file
pub content: String,
}
/// Find matching agent config files in a directory.
///
/// `filenames` is the (compat-gated) recognized list, precomputed once by the
/// caller so the cwd→root walk doesn't re-allocate it per directory. When all
/// cells are on it equals the legacy `AGENT_FILENAMES` list exactly.
fn find_agent_files(dir: &Path, filenames: &[&str]) -> Vec<PathBuf> {
filenames
.iter()
.filter_map(|name| {
let path = dir.join(name);
path.exists().then_some(path)
})
.collect()
}
/// Find `*.md` files in `.kigi/rules/`, `.claude/rules/`, and `.cursor/rules/`,
/// sorted alphabetically. `rules_subdirs` is the (compat-gated) list, precomputed
/// once by the caller so the walk doesn't re-allocate it per directory.
fn find_rules_files(dir: &Path, rules_subdirs: &[&str]) -> Vec<PathBuf> {
let mut results = Vec::new();
for rules_subdir in rules_subdirs {
let rules_dir = dir.join(rules_subdir);
if !rules_dir.is_dir() {
continue;
}
let mut entries: Vec<PathBuf> = match std::fs::read_dir(&rules_dir) {
Ok(iter) => iter
.filter_map(|entry| entry.ok())
.map(|e| e.path())
.filter(|p| {
p.extension()
.and_then(|ext| ext.to_str())
.is_some_and(|ext| ext.eq_ignore_ascii_case("md"))
})
.collect(),
Err(_) => continue,
};
entries.sort_by(|a, b| a.file_name().cmp(&b.file_name()));
results.extend(entries);
}
results
}
/// Read Agents.md from ~/.kigi/, git repo root, and session cwd.
/// Returns a list of AgentConfigFile with their file names, full paths, and contents.
///
/// `compat` gates which vendor (`.claude`/`.cursor`) surfaces are scanned for
/// rules / project-instruction files; pass `CompatConfig::default()` to
/// preserve the historical all-vendors behavior.
pub async fn read_agents_config_with_paths(
working_directory: &str,
compat: CompatConfig,
) -> Vec<AgentConfigFile> {
let workspace_user_dir = crate::prompt::workspace_user::optional_workspace_user_dir();
read_agents_config_with_options(working_directory, workspace_user_dir.as_deref(), compat).await
}
/// Inner implementation that accepts an optional workspace user dir as a
/// parameter, making it testable without environment variable mutation.
async fn read_agents_config_with_options(
working_directory: &str,
workspace_user_dir: Option<&Path>,
compat: CompatConfig,
) -> Vec<AgentConfigFile> {
let cwd = PathBuf::from(working_directory);
let global_dir = kigi_tools::util::kigi_home::kigi_home();
let git_root = git2::Repository::discover(&cwd)
.ok()
.and_then(|repo| repo.workdir().map(|p| p.to_path_buf()));
let gitignore = build_gitignore(git_root.as_deref());
// Always include kigi_home (~/.kigi/) first, then ~/.claude/ and ~/.cursor/
// for compat — each gated by the resolved `agents` compat cell.
let mut dirs = vec![global_dir];
if let Some(home) = dirs::home_dir() {
for compat_dir in compat.agents_home_dirs() {
let dir = home.join(compat_dir);
if dir.is_dir() {
dirs.push(dir);
}
}
}
// Walk from cwd up to git root to pick up agent files in intermediate directories
if let Some(ref root) = git_root {
let mut current = Some(cwd.as_path());
let mut chain: Vec<PathBuf> = Vec::new();
while let Some(dir) = current {
let dir_buf = dir.to_path_buf();
if !chain.contains(&dir_buf) {
chain.push(dir_buf);
}
if dir == root.as_path() {
break;
}
current = dir.parent();
}
// CRITICAL: Reverse to get root → CWD order (deeper files come later)
chain.reverse();
// Inject optional workspace user dir if not already in the chain.
// Insert after repo root (index 0 after reverse) so it's higher priority
// than repo root AGENTS.md but lower priority than intermediate dirs and cwd.
if let Some(user_dir) = workspace_user_dir {
let user_dir_canonical =
dunce::canonicalize(user_dir).unwrap_or_else(|_| user_dir.to_path_buf());
let already_in_chain = chain.iter().any(|d| {
dunce::canonicalize(d).unwrap_or_else(|_| d.clone()) == user_dir_canonical
});
if !already_in_chain {
// chain[0] is repo root after reverse; insert right after it.
let insert_pos = 1.min(chain.len());
chain.insert(insert_pos, user_dir.to_path_buf());
}
}
dirs.extend(chain);
} else if !dirs.contains(&cwd) {
dirs.push(cwd.clone());
}
// Compute the gated lists once (constant across all scanned dirs) so the
// per-directory scan below doesn't re-allocate them.
let agent_filenames = compat.agent_filenames();
let rules_dirs = compat.rules_dirs();
let files: Vec<PathBuf> = dirs
.into_iter()
.flat_map(|dir| {
let mut combined = find_agent_files(&dir, &agent_filenames);
combined.extend(find_rules_files(&dir, &rules_dirs));
combined
})
.filter(|path| !is_ignored(path, gitignore.as_ref(), git_root.as_deref()))
.collect();
// Deduplicate by canonical path to handle case-insensitive filesystems
// and symlink-resolved tmpdir paths.
let mut seen_canonical = std::collections::HashSet::new();
files
.into_iter()
.filter(|path| {
let canonical = dunce::canonicalize(path).unwrap_or_else(|_| path.clone());
seen_canonical.insert(canonical)
})
.filter_map(|file_path| {
let content = std::fs::read_to_string(&file_path).ok()?;
let file_name = file_path
.file_name()
.and_then(|f| f.to_str())
.unwrap_or("AGENTS.md")
.to_string();
let full_path = file_path.display().to_string();
Some(AgentConfigFile {
file_name,
file_path: full_path,
content,
})
})
.collect()
}
/// Format AGENTS.md configs into a `<system-reminder>` block for user message injection.
pub fn format_agents_md_section(configs: &[AgentConfigFile]) -> Option<String> {
render_agents_md(configs)
}
/// Verbatim leading bytes [`render_agents_md`] emits for every reminder block.
/// Used by `kigi-shell` to structurally detect legacy untagged AGENTS.md
/// copies (pre-`SyntheticReason::ProjectInstructions`) on resumed sessions.
pub const LEGACY_AGENTS_MD_REMINDER_PREFIX: &str =
"\n\n<system-reminder>\nAs you answer the user's questions, you can use the following context";
fn render_agents_md(configs: &[AgentConfigFile]) -> Option<String> {
if configs.is_empty() {
return None;
}
let mut section = String::new();
section.push_str(LEGACY_AGENTS_MD_REMINDER_PREFIX);
section.push_str(
" (ordered from repo root to current directory - deeper files take precedence on conflicts):\n",
);
for config in configs {
section.push_str(&format!("\n## From: {}\n", config.file_path));
// Strip YAML frontmatter from rules files (e.g. .claude/rules/*.md,
// .kigi/rules/*.md) so globs/paths metadata doesn't leak into the
// system prompt as raw YAML.
let is_rules_file = config.file_path.contains("/.kigi/rules/")
|| config.file_path.contains("/.claude/rules/");
let content = if is_rules_file {
kigi_tools::implementations::skills::skill::extract_skill_body(&config.content)
} else {
config.content.clone()
};
section.push_str(&content);
section.push('\n');
}
section.push_str("\nFollow these instructions exactly. When working in subdirectories not listed above, check for additional project instruction files (AGENTS.md, Claude.md, etc.).");
section.push_str("\n</system-reminder>");
Some(section)
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
/// Helper: initialize a git repo at `path` so git2::Repository::discover works.
fn init_git_repo(path: &Path) {
git2::Repository::init(path).unwrap();
}
// ── find_agent_files unit tests ─────────────────────────────────
#[test]
fn find_agent_files_finds_agents_md() {
let tmp = tempfile::tempdir().unwrap();
fs::write(tmp.path().join("AGENTS.md"), "# Instructions").unwrap();
let files = find_agent_files(tmp.path(), &CompatConfig::default().agent_filenames());
// On case-insensitive filesystems (macOS), both "Agents.md" and "AGENTS.md"
// resolve to the same file, so we may get more than 1 result.
assert!(!files.is_empty());
assert!(
files
.iter()
.any(|f| f.to_string_lossy().contains("AGENTS.md")
|| f.to_string_lossy().contains("Agents.md"))
);
}
#[test]
fn find_agent_files_finds_all_variants() {
let tmp = tempfile::tempdir().unwrap();
let filenames = CompatConfig::default().agent_filenames();
for name in &filenames {
let path = tmp.path().join(name);
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).unwrap();
}
fs::write(&path, format!("# {name}")).unwrap();
}
let files = find_agent_files(tmp.path(), &filenames);
assert_eq!(files.len(), filenames.len());
}
#[test]
fn find_agent_files_empty_dir() {
let tmp = tempfile::tempdir().unwrap();
let files = find_agent_files(tmp.path(), &CompatConfig::default().agent_filenames());
assert!(files.is_empty());
}
#[test]
fn find_agent_files_nonexistent_dir() {
let files = find_agent_files(
Path::new("/nonexistent/dir"),
&CompatConfig::default().agent_filenames(),
);
assert!(files.is_empty());
}
#[test]
fn find_agent_files_discovers_claude_subdir() {
let tmp = tempfile::tempdir().unwrap();
let claude_dir = tmp.path().join(".claude");
fs::create_dir_all(&claude_dir).unwrap();
fs::write(claude_dir.join("CLAUDE.md"), "# Project instructions").unwrap();
let files = find_agent_files(tmp.path(), &CompatConfig::default().agent_filenames());
assert!(
files
.iter()
.any(|f| f.to_string_lossy().contains(".claude/CLAUDE.md")),
"Should discover .claude/CLAUDE.md, got: {files:?}"
);
}
#[test]
fn find_rules_files_discovers_claude_rules() {
let tmp = tempfile::tempdir().unwrap();
let rules_dir = tmp.path().join(".claude").join("rules");
fs::create_dir_all(&rules_dir).unwrap();
fs::write(rules_dir.join("style.md"), "# Style rules").unwrap();
fs::write(rules_dir.join("safety.md"), "# Safety rules").unwrap();
let files = find_rules_files(tmp.path(), &CompatConfig::default().rules_dirs());
assert_eq!(files.len(), 2);
assert!(files[0].to_string_lossy().contains("safety.md"));
assert!(files[1].to_string_lossy().contains("style.md"));
}
// ── format_agents_md_section tests ──────────────────────────────
#[test]
fn format_agents_md_section_empty_returns_none() {
assert!(format_agents_md_section(&[]).is_none());
}
#[test]
fn format_agents_md_section_includes_all_configs() {
let configs = vec![
AgentConfigFile {
file_name: "AGENTS.md".to_string(),
file_path: "/repo/AGENTS.md".to_string(),
content: "Repo-level instructions".to_string(),
},
AgentConfigFile {
file_name: "AGENTS.md".to_string(),
file_path: "/repo/x/user/AGENTS.md".to_string(),
content: "User-level instructions".to_string(),
},
];
let section = format_agents_md_section(&configs).unwrap();
assert!(section.contains("Repo-level instructions"));
assert!(section.contains("User-level instructions"));
assert!(section.contains("/repo/AGENTS.md"));
assert!(section.contains("/repo/x/user/AGENTS.md"));
assert!(section.contains("<system-reminder>"));
}
#[test]
fn format_agents_md_section_delivers_full_content() {
let long_content = "A".repeat(5000);
let configs = vec![AgentConfigFile {
file_name: "AGENTS.md".to_string(),
file_path: "/repo/AGENTS.md".to_string(),
content: long_content,
}];
let section = format_agents_md_section(&configs).unwrap();
// No cap: the full content is delivered verbatim, with no truncation marker.
assert!(
section.contains(&"A".repeat(5000)),
"full content must be preserved"
);
assert!(
!section.contains("truncated"),
"content must not be truncated"
);
}
// ── Feature 2: Workspace user AGENTS.md via read_agents_config ───
#[tokio::test]
async fn read_agents_config_includes_workspace_user_agents_md() {
let tmp = tempfile::tempdir().unwrap();
let repo_root = tmp.path().join("repo");
fs::create_dir_all(&repo_root).unwrap();
init_git_repo(&repo_root);
// Create user AGENTS.md
let user_dir = repo_root.join("x").join("testuser");
fs::create_dir_all(&user_dir).unwrap();
fs::write(
user_dir.join("AGENTS.md"),
"# User-specific instructions\nAlways use tabs.",
)
.unwrap();
// cwd = repo root (user dir is NOT in the walk path)
let configs = read_agents_config_with_options(
repo_root.to_str().unwrap(),
Some(&user_dir),
CompatConfig::default(),
)
.await;
let contents: Vec<&str> = configs.iter().map(|c| c.content.as_str()).collect();
assert!(
contents.iter().any(|c| c.contains("Always use tabs")),
"Workspace user AGENTS.md should be included, got: {contents:?}"
);
}
#[tokio::test]
async fn read_agents_config_workspace_user_dedup_when_cwd_inside_user_dir() {
let tmp = tempfile::tempdir().unwrap();
let repo_root = tmp.path().join("repo");
fs::create_dir_all(&repo_root).unwrap();
init_git_repo(&repo_root);
// User dir with AGENTS.md
let user_dir = repo_root.join("x").join("testuser");
fs::create_dir_all(&user_dir).unwrap();
fs::write(user_dir.join("AGENTS.md"), "# Dedup test instructions").unwrap();
// cwd IS the user dir — the walk already includes it
let configs = read_agents_config_with_options(
user_dir.to_str().unwrap(),
Some(&user_dir),
CompatConfig::default(),
)
.await;
// "Dedup test instructions" should appear exactly once
let count = configs
.iter()
.filter(|c| c.content.contains("Dedup test instructions"))
.count();
assert_eq!(
count, 1,
"User AGENTS.md should appear exactly once, got {count}"
);
}
#[tokio::test]
async fn read_agents_config_no_workspace_user_dir_no_user_agents_md() {
let tmp = tempfile::tempdir().unwrap();
let repo_root = tmp.path().join("repo");
fs::create_dir_all(&repo_root).unwrap();
init_git_repo(&repo_root);
// User dir with AGENTS.md (should NOT be found)
let user_dir = repo_root.join("x").join("ghost");
fs::create_dir_all(&user_dir).unwrap();
fs::write(user_dir.join("AGENTS.md"), "# Ghost instructions").unwrap();
// Pass None — simulates env vars not set
let configs = read_agents_config_with_options(
repo_root.to_str().unwrap(),
None,
CompatConfig::default(),
)
.await;
let has_ghost = configs
.iter()
.any(|c| c.content.contains("Ghost instructions"));
assert!(
!has_ghost,
"Without optional workspace user dir, ghost AGENTS.md should not be found"
);
}
/// Regression: running outside a git repo must not panic.
#[tokio::test]
async fn regression_no_panic_outside_git_repo() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path().join("not_a_repo");
fs::create_dir_all(&dir).unwrap();
fs::write(dir.join("AGENTS.md"), "# outside git").unwrap();
let configs =
read_agents_config_with_options(dir.to_str().unwrap(), None, CompatConfig::default())
.await;
assert!(configs.iter().any(|c| c.content.contains("outside git")));
}
#[tokio::test]
async fn read_agents_config_workspace_user_and_repo_root_both_found() {
let tmp = tempfile::tempdir().unwrap();
let repo_root = tmp.path().join("repo");
fs::create_dir_all(&repo_root).unwrap();
init_git_repo(&repo_root);
// Repo root AGENTS.md
fs::write(repo_root.join("AGENTS.md"), "# XYZZY_REPO_ROOT_MARKER").unwrap();
// User AGENTS.md
let user_dir = repo_root.join("x").join("testuser");
fs::create_dir_all(&user_dir).unwrap();
fs::write(user_dir.join("AGENTS.md"), "# XYZZY_USER_SPECIFIC_MARKER").unwrap();
let configs = read_agents_config_with_options(
repo_root.to_str().unwrap(),
Some(&user_dir),
CompatConfig::default(),
)
.await;
// Both should be found
let has_repo = configs
.iter()
.any(|c| c.content.contains("XYZZY_REPO_ROOT_MARKER"));
let has_user = configs
.iter()
.any(|c| c.content.contains("XYZZY_USER_SPECIFIC_MARKER"));
assert!(
has_repo,
"Repo root AGENTS.md not found in: {:?}",
configs
.iter()
.map(|c| (&c.file_path, &c.content))
.collect::<Vec<_>>()
);
assert!(
has_user,
"User AGENTS.md not found in: {:?}",
configs
.iter()
.map(|c| (&c.file_path, &c.content))
.collect::<Vec<_>>()
);
}
#[test]
fn render_strips_frontmatter_from_rules_files() {
let configs = vec![AgentConfigFile {
file_name: "style.md".to_string(),
file_path: "/repo/.claude/rules/style.md".to_string(),
content: "---\nglobs: [\"*.rs\"]\n---\n# Use snake_case".to_string(),
}];
let section = format_agents_md_section(&configs).unwrap();
assert!(section.contains("# Use snake_case"));
assert!(!section.contains("globs:"));
}
// ── .claude/CLAUDE.md integration tests ─────────────────────────
#[tokio::test]
async fn read_agents_config_discovers_claude_subdir_claude_md() {
let tmp = tempfile::tempdir().unwrap();
let repo_root = tmp.path().join("repo");
fs::create_dir_all(&repo_root).unwrap();
init_git_repo(&repo_root);
// .claude/CLAUDE.md at repo root
let claude_dir = repo_root.join(".claude");
fs::create_dir_all(&claude_dir).unwrap();
fs::write(claude_dir.join("CLAUDE.md"), "# XYZZY_CLAUDE_SUBDIR_MARKER").unwrap();
let configs = read_agents_config_with_options(
repo_root.to_str().unwrap(),
None,
CompatConfig::default(),
)
.await;
assert!(
configs
.iter()
.any(|c| c.content.contains("XYZZY_CLAUDE_SUBDIR_MARKER")),
".claude/CLAUDE.md should be discovered, got: {:?}",
configs
.iter()
.map(|c| (&c.file_path, &c.content))
.collect::<Vec<_>>()
);
}
#[tokio::test]
async fn read_agents_config_claude_subdir_and_direct_both_found() {
let tmp = tempfile::tempdir().unwrap();
let repo_root = tmp.path().join("repo");
fs::create_dir_all(&repo_root).unwrap();
init_git_repo(&repo_root);
// Direct CLAUDE.md
fs::write(repo_root.join("CLAUDE.md"), "# XYZZY_DIRECT_MARKER").unwrap();
// .claude/CLAUDE.md
let claude_dir = repo_root.join(".claude");
fs::create_dir_all(&claude_dir).unwrap();
fs::write(claude_dir.join("CLAUDE.md"), "# XYZZY_SUBDIR_MARKER").unwrap();
let configs = read_agents_config_with_options(
repo_root.to_str().unwrap(),
None,
CompatConfig::default(),
)
.await;
let has_direct = configs
.iter()
.any(|c| c.content.contains("XYZZY_DIRECT_MARKER"));
let has_subdir = configs
.iter()
.any(|c| c.content.contains("XYZZY_SUBDIR_MARKER"));
assert!(has_direct, "Direct CLAUDE.md should be found");
assert!(has_subdir, ".claude/CLAUDE.md should be found");
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,37 @@
//! Gitignore integration for AGENTS.md and skills discovery.
use ignore::gitignore::{Gitignore, GitignoreBuilder};
use std::path::{Path, PathBuf};
pub fn build_gitignore(repo_root: Option<&Path>) -> Option<Gitignore> {
// No repo root → no gitignore rules to apply.
let root = repo_root?;
let mut builder = GitignoreBuilder::new(root);
let repo_gitignore = root.join(".gitignore");
if repo_gitignore.exists() {
let _ = builder.add(&repo_gitignore);
}
if let Some(global_path) = get_global_gitignore_path()
&& global_path.exists()
{
let _ = builder.add(&global_path);
}
builder.build().ok()
}
pub fn is_ignored(path: &Path, gitignore: Option<&Gitignore>, repo_root: Option<&Path>) -> bool {
let Some(gi) = gitignore else {
return false;
};
kigi_tools::gitignore::is_ignored(gi, path, repo_root)
}
fn get_global_gitignore_path() -> Option<PathBuf> {
git2::Config::open_default()
.ok()
.and_then(|cfg| cfg.get_path("core.excludesFile").ok())
.or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".gitignore")))
}
@@ -0,0 +1,9 @@
//! System prompt assembly — template rendering, AGENTS.md, and skills.
pub mod agents_md;
pub mod context;
pub mod ignore;
pub mod skills;
pub mod subagent_prompts;
pub mod template;
pub mod user_message;
pub mod workspace_user;
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,26 @@
//! System prompts for built-in subagent profiles.
//!
//!
//! ## Tool name resolution
//!
//! All tool names in these prompts use the `${{ tools.by_kind.* }}` template
//! syntax from the `TemplateRenderer`. When the prompt is rendered via
//! `PromptContext::render()` → `ToolBridge::render_prompt()`, MiniJinja
//! resolves each variable to the current session's tool names.
//!
//! This means:
//! - Tool names are NEVER hardcoded — they adapt to name overrides and
//! alternate tool namespaces
//! - If a tool kind is absent from the renderer's context, MiniJinja
//! resolves it to an empty string (templates can also use
//! `${%- if tools.by_kind.X %}` conditionals to hide entire sections)
//!
//! Tool-kind mapping (common names → ToolKind):
//! Read → `${{ tools.by_kind.read }}`
//! Write/Edit → `${{ tools.by_kind.edit }}`
//! Glob → `${{ tools.by_kind.list }}`
//! Grep → `${{ tools.by_kind.search }}`
//! Bash → `${{ tools.by_kind.execute }}`
//! WebSearch → `${{ tools.by_kind.web_search }}`
pub use kigi_tool_types::{EXPLORE_PROMPT, GENERAL_PURPOSE_PROMPT, PLAN_PROMPT};
@@ -0,0 +1,880 @@
//! System prompt template source and constants.
//!
//! Templates are XOR-obfuscated by `scripts/encrypt_templates.py` (obfuscation,
//! not security — seeds live in-repo) so they don't appear as obvious plaintext
//! in `strings` output. They are decrypted on demand and the returned
//! `Zeroizing<String>` wipes the plaintext from memory on drop.
use zeroize::Zeroizing;
// Encrypted template bytes (pre-generated by scripts/encrypt_templates.py).
#[path = "prompt_encrypted.rs"]
mod prompt_encrypted;
use prompt_encrypted::*;
/// Decrypt XOR-obfuscated template data (mirrors `scripts/encrypt_templates.py::xor_encrypt`).
/// Obfuscation only — not a security boundary.
fn decrypt(data: &[u8], seed: u8) -> Zeroizing<String> {
let bytes: Vec<u8> = data
.iter()
.enumerate()
.map(|(i, &b)| b ^ seed.wrapping_add(i as u8))
.collect();
Zeroizing::new(String::from_utf8(bytes).expect(
"prompt template decryption produced invalid UTF-8 — \
prompt_encrypted.rs is likely stale; run: \
python3 scripts/encrypt_templates.py",
))
}
/// The base prompt template (decrypted fresh; zeroed on drop).
pub(crate) fn base_template() -> Zeroizing<String> {
decrypt(BASE_PROMPT_ENC, PROMPT_SEEDS[0])
}
/// The base prompt template source, exposed for `grok prompt --section template`.
pub fn base_template_source() -> Zeroizing<String> {
base_template()
}
pub(crate) fn apply_patch_template() -> Zeroizing<String> {
decrypt(CODEX_PROMPT_ENC, PROMPT_SEEDS[1])
}
/// Apply-patch prompt template source, exposed for `grok prompt --section apply-patch-template`.
pub fn apply_patch_template_source() -> Zeroizing<String> {
apply_patch_template()
}
/// The subagent-specific base template (decrypted fresh; zeroed on drop).
pub(crate) fn subagent_template() -> Zeroizing<String> {
decrypt(SUBAGENT_PROMPT_ENC, PROMPT_SEEDS[2])
}
/// The compact system prompt used after conversation compaction.
pub const COMPACT_SYSTEM_PROMPT: &str = "You are an AI coding agent. You operate in a workspace with a provided codebase.\n\n\
Your main goal is to complete the user's request, denoted within the <user_query> tag.";
#[cfg(test)]
mod tests {
use super::*;
use kigi_tools::types::template_renderer::TemplateRenderer;
use kigi_tools::types::tool::ToolKind;
use std::collections::HashMap;
/// Verify the pre-generated encrypted file matches the current template sources.
/// If this fails, run: `python3 scripts/encrypt_templates.py`
#[test]
fn test_encrypted_templates_not_stale() {
fn xor_encrypt(data: &[u8], seed: u8) -> Vec<u8> {
data.iter()
.enumerate()
.map(|(i, &b)| b ^ seed.wrapping_add(i as u8))
.collect()
}
let base_raw = include_bytes!("../../templates/prompt.md");
let apply_patch_raw = include_bytes!("../../templates/apply_patch_prompt.md");
let subagent_raw = include_bytes!("../../templates/subagent_prompt.md");
assert_eq!(
BASE_PROMPT_ENC,
&xor_encrypt(base_raw, PROMPT_SEEDS[0]),
"prompt.md encrypted bytes are stale — run scripts/encrypt_templates.py"
);
assert_eq!(
CODEX_PROMPT_ENC,
&xor_encrypt(apply_patch_raw, PROMPT_SEEDS[1]),
"apply_patch_prompt.md encrypted bytes are stale — run scripts/encrypt_templates.py"
);
assert_eq!(
SUBAGENT_PROMPT_ENC,
&xor_encrypt(subagent_raw, PROMPT_SEEDS[2]),
"subagent_prompt.md encrypted bytes are stale — run scripts/encrypt_templates.py"
);
}
/// Build a TemplateRenderer with the standard grok-build tool kinds.
fn default_renderer() -> TemplateRenderer {
let tools: HashMap<ToolKind, String> = [
(ToolKind::Read, "read_file"),
(ToolKind::Edit, "search_replace"),
(ToolKind::Execute, "run_terminal_command"),
(ToolKind::Search, "grep"),
(ToolKind::List, "list_dir"),
(ToolKind::Plan, "todo_write"),
(ToolKind::Skill, "skill"),
(
ToolKind::BackgroundTaskAction,
"get_command_or_subagent_output",
),
(ToolKind::KillTaskAction, "kill_command_or_subagent"),
(ToolKind::WebSearch, "web_search"),
]
.into_iter()
.map(|(k, v)| (k, v.to_string()))
.collect();
TemplateRenderer::new(tools, HashMap::new())
}
fn default_placeholders() -> serde_json::Value {
serde_json::json!({
"os_name": "macos",
"shell_path": "/bin/zsh",
"working_directory": "/tmp/test",
"current_date": "2025-01-15",
"memory_enabled": false,
"is_non_interactive": false,
"system_prompt_label": crate::prompt::context::DEFAULT_SYSTEM_PROMPT_LABEL,
})
}
fn render_base(renderer: &TemplateRenderer, placeholders: &serde_json::Value) -> String {
let tmpl = base_template();
renderer
.render_with_extra(&tmpl, placeholders)
.expect("base template render failed")
}
fn render_subagent(renderer: &TemplateRenderer, placeholders: &serde_json::Value) -> String {
let tmpl = subagent_template();
renderer
.render_with_extra(&tmpl, placeholders)
.expect("subagent template render failed")
}
fn render_apply_patch(renderer: &TemplateRenderer, placeholders: &serde_json::Value) -> String {
let tmpl = apply_patch_template();
renderer
.render_with_extra(&tmpl, placeholders)
.expect("codex template render failed")
}
// ── Variable substitution ───────────────────────────────────────
#[test]
fn test_variable_substitution_tool_kind() {
let r = default_renderer();
let p = default_placeholders();
let result = r
.render_with_extra("Use ${{ tools.by_kind.read }} to read files.", &p)
.unwrap();
assert_eq!(result, "Use read_file to read files.");
}
#[test]
fn test_variable_substitution_agent_fields() {
let r = default_renderer();
let p = default_placeholders();
let result = r
.render_with_extra("OS: ${{ os_name }}, Shell: ${{ shell_path }}", &p)
.unwrap();
assert_eq!(result, "OS: macos, Shell: /bin/zsh");
}
// ── Conditionals ────────────────────────────────────────────────
#[test]
fn test_conditional_tool_present() {
let r = default_renderer();
let p = default_placeholders();
let result = r
.render_with_extra("${%- if tools.by_kind.plan %}show${%- endif %}", &p)
.unwrap();
assert_eq!(result, "show");
}
#[test]
fn test_conditional_tool_absent() {
// Renderer without plan tool
let tools: HashMap<ToolKind, String> = [(ToolKind::Read, "read_file".to_string())].into();
let r = TemplateRenderer::new(tools, HashMap::new());
let p = default_placeholders();
let result = r
.render_with_extra("${%- if tools.by_kind.plan %}show${%- endif %}", &p)
.unwrap();
assert_eq!(result, "");
}
#[test]
fn test_literal_braces_pass_through() {
let r = default_renderer();
let p = default_placeholders();
let result = r
.render_with_extra("Use {{ literal_braces }} in prose.", &p)
.unwrap();
assert_eq!(result, "Use {{ literal_braces }} in prose.");
}
// ── Tool name overrides ─────────────────────────────────────────
#[test]
fn test_tool_name_override() {
let tools: HashMap<ToolKind, String> = [
(ToolKind::Read, "view_file".to_string()),
(ToolKind::Edit, "Edit".to_string()),
]
.into();
let r = TemplateRenderer::new(tools, HashMap::new());
let p = default_placeholders();
let result = r
.render_with_extra(
"Use ${{ tools.by_kind.read }} and ${{ tools.by_kind.edit }}.",
&p,
)
.unwrap();
assert_eq!(result, "Use view_file and Edit.");
}
// ── Base template rendering ─────────────────────────────────────
#[test]
fn test_base_template_renders() {
let prompt = render_base(&default_renderer(), &default_placeholders());
assert!(prompt.contains(crate::prompt::context::DEFAULT_SYSTEM_PROMPT_LABEL));
assert!(prompt.contains("user_query"));
}
#[test]
fn test_base_template_contains_resolved_tool_names() {
let prompt = render_base(&default_renderer(), &default_placeholders());
// The minimal prompt only resolves the read/edit tool names, inside
// <tool_calling>. (todo_write / run_terminal_command lived in sections
// that the trimmed prompt no longer renders.)
assert!(prompt.contains("read_file"), "Should contain 'read_file'");
assert!(
prompt.contains("search_replace"),
"Should contain 'search_replace'"
);
assert!(!prompt.contains("${{"), "No unresolved template variables");
assert!(!prompt.contains("${%"), "No unresolved template blocks");
}
#[test]
fn test_base_template_with_overridden_tool_names() {
let tools: HashMap<ToolKind, String> = [
(ToolKind::Read, "view_file".to_string()),
(ToolKind::Edit, "edit".to_string()),
(ToolKind::Execute, "run_terminal_cmd".to_string()),
(ToolKind::Search, "grep".to_string()),
(ToolKind::Plan, "todo_write".to_string()),
(
ToolKind::BackgroundTaskAction,
"get_task_output".to_string(),
),
]
.into();
let r = TemplateRenderer::new(tools, HashMap::new());
let prompt = render_base(&r, &default_placeholders());
assert!(
prompt.contains("`view_file`"),
"Should use overridden 'view_file'"
);
assert!(prompt.contains("`edit`"), "Should use overridden 'edit'");
assert!(
!prompt.contains("`read_file`"),
"Should NOT contain canonical 'read_file'"
);
}
#[test]
fn test_base_template_plan_absent_omits_task_management() {
// Renderer without Plan tool
let tools: HashMap<ToolKind, String> = [
(ToolKind::Read, "read_file".to_string()),
(ToolKind::Execute, "run_terminal_cmd".to_string()),
(
ToolKind::BackgroundTaskAction,
"get_task_output".to_string(),
),
]
.into();
let r = TemplateRenderer::new(tools, HashMap::new());
let prompt = render_base(&r, &default_placeholders());
assert!(
!prompt.contains("Task Management"),
"Task Management section should be omitted"
);
}
#[test]
fn test_base_template_execute_absent_omits_background_tasks() {
// Renderer without Execute tool
let tools: HashMap<ToolKind, String> = [(ToolKind::Plan, "todo_write".to_string())].into();
let r = TemplateRenderer::new(tools, HashMap::new());
let prompt = render_base(&r, &default_placeholders());
assert!(
!prompt.contains("background_tasks"),
"background_tasks section should be omitted"
);
}
#[test]
fn test_monitor_tool_renders_watch_section() {
let tools: HashMap<ToolKind, String> = [
(ToolKind::Execute, "run_command".to_string()),
(ToolKind::BackgroundTaskAction, "get_output".to_string()),
(ToolKind::KillTaskAction, "kill_task".to_string()),
(ToolKind::Monitor, "monitor".to_string()),
]
.into_iter()
.collect();
let r = TemplateRenderer::new(tools, HashMap::new());
let prompt = render_base(&r, &default_placeholders());
assert!(
prompt.contains("For watch processes"),
"monitor section should render when Monitor tool is present"
);
assert!(
prompt.contains("streams each stdout line back as a chat notification"),
"monitor section should describe streaming stdout as notifications"
);
assert!(
prompt.contains("Use the `monitor` tool"),
"monitor section should resolve the Monitor tool name"
);
}
#[test]
fn test_no_monitor_tool_omits_watch_section() {
let tools: HashMap<ToolKind, String> = [
(ToolKind::Execute, "run_command".to_string()),
(ToolKind::BackgroundTaskAction, "get_output".to_string()),
(ToolKind::KillTaskAction, "kill_task".to_string()),
]
.into_iter()
.collect();
let r = TemplateRenderer::new(tools, HashMap::new());
let prompt = render_base(&r, &default_placeholders());
assert!(
!prompt.contains("For watch processes"),
"monitor section should NOT render without Monitor tool"
);
assert!(
!prompt.contains("<background_tasks>"),
"background_tasks section is gated on the Monitor tool and is omitted without it"
);
}
// ── Required sections regression ────────────────────────────────
#[test]
fn test_base_template_contains_required_sections() {
let p = default_placeholders();
let prompt = render_base(&default_renderer(), &p);
assert!(
prompt.contains(crate::prompt::context::DEFAULT_SYSTEM_PROMPT_LABEL),
"Must contain agent identity"
);
assert!(
prompt.contains("user_query"),
"Must reference user_query tag"
);
}
#[test]
fn test_compact_prompt_matches_expected() {
assert_eq!(
COMPACT_SYSTEM_PROMPT,
"You are an AI coding agent. You operate in a workspace with a provided codebase.\n\n\
Your main goal is to complete the user's request, denoted within the <user_query> tag.",
);
}
// ── Mid-session mode switching ──────────────────────────────────
#[test]
fn test_mid_session_switch_concise_to_full() {
let compact = COMPACT_SYSTEM_PROMPT;
assert!(!compact.contains("read_file"), "Compact has no tool names");
assert!(
!compact.contains("<tool_calling>"),
"Compact has no tool section"
);
let full = render_base(&default_renderer(), &default_placeholders());
assert!(
full.contains("<tool_calling>"),
"Full prompt has tool section"
);
assert!(full.contains("read_file"), "Full prompt has read_file");
assert!(
full.contains("search_replace"),
"Full prompt has search_replace"
);
}
#[test]
fn test_mid_session_switch_preserves_tool_overrides() {
let tools: HashMap<ToolKind, String> = [
(ToolKind::Read, "view".to_string()),
(ToolKind::Edit, "edit".to_string()),
(ToolKind::Execute, "run_terminal_cmd".to_string()),
(ToolKind::Plan, "todo_write".to_string()),
(
ToolKind::BackgroundTaskAction,
"get_task_output".to_string(),
),
]
.into();
let r = TemplateRenderer::new(tools, HashMap::new());
let prompt = render_base(&r, &default_placeholders());
assert!(prompt.contains("`edit`"), "Should use overridden 'edit'");
assert!(prompt.contains("`view`"), "Should use overridden 'view'");
assert!(
!prompt.contains("`read_file`"),
"Should not contain original 'read_file'"
);
assert!(
!prompt.contains("`search_replace`"),
"Should not contain original 'search_replace'"
);
}
// ── Determinism ─────────────────────────────────────────────────
#[test]
fn test_prompt_deterministic_across_renders() {
let r = default_renderer();
let p = default_placeholders();
let a = render_base(&r, &p);
let b = render_base(&r, &p);
assert_eq!(a, b, "Prompt rendering must be deterministic");
}
#[test]
fn test_full_mode_deterministic() {
let r = default_renderer();
let p = default_placeholders();
let body = "Agent: ${{ tools.by_kind.read }}, OS: ${{ os_name }}";
let a = r.render_with_extra(body, &p).unwrap();
let b = r.render_with_extra(body, &p).unwrap();
assert_eq!(a, b, "Full mode rendering must be deterministic");
}
// ── Disabled tools ──────────────────────────────────────────────
#[test]
fn test_disabled_tools_omit_sections() {
// No plan, no execute
let tools: HashMap<ToolKind, String> = [(ToolKind::Read, "read_file".to_string())].into();
let r = TemplateRenderer::new(tools, HashMap::new());
let prompt = render_base(&r, &default_placeholders());
assert!(
!prompt.contains("Task Management"),
"Task Management must be omitted"
);
assert!(
!prompt.contains("background_tasks"),
"background_tasks must be omitted"
);
}
// ── Memory section ──────────────────────────────────────────────
#[test]
fn test_memory_enabled_does_not_render_memory_section() {
// The <memory> section was removed from the minimal base prompt.
// Even when the memory tools are registered AND memory_enabled=true,
// the trimmed template must not render a memory section. (Complements
// test_memory_disabled_omits_memory_section, which covers the default.)
let tools: HashMap<ToolKind, String> = [
(ToolKind::Read, "read_file".to_string()),
(ToolKind::MemorySearch, "memory_search".to_string()),
(ToolKind::MemoryGet, "memory_get".to_string()),
]
.into();
let r = TemplateRenderer::new(tools, HashMap::new());
let mut p = default_placeholders();
p["memory_enabled"] = serde_json::json!(true);
let prompt = render_base(&r, &p);
assert!(
!prompt.contains("<memory>"),
"Memory section was removed from the minimal prompt"
);
assert!(
!prompt.contains("### Memory Management"),
"Memory Management section was removed from the minimal prompt"
);
assert!(
!prompt.contains("memory_search"),
"memory tool names must not appear once the memory section is gone"
);
assert!(
!prompt.contains("memory_get"),
"memory tool names must not appear once the memory section is gone"
);
}
#[test]
fn test_memory_disabled_omits_memory_section() {
let prompt = render_base(&default_renderer(), &default_placeholders());
assert!(
!prompt.contains("<memory>"),
"Memory section must be omitted"
);
}
// ── Web search disabled ─────────────────────────────────────────
#[test]
fn test_web_search_disabled_renders_without_crash() {
// No Fetch tool
let tools: HashMap<ToolKind, String> = [
(ToolKind::Read, "read_file".to_string()),
(ToolKind::Plan, "todo_write".to_string()),
]
.into();
let r = TemplateRenderer::new(tools, HashMap::new());
let tmpl = base_template();
let result = r.render_with_extra(&tmpl, &default_placeholders());
assert!(
result.is_ok(),
"Must render without crash: {:?}",
result.err()
);
}
// ── Apply-patch template rendering ───────────────────────────────────
#[test]
fn test_apply_patch_template_renders() {
let prompt = render_apply_patch(&default_renderer(), &default_placeholders());
assert!(prompt.contains("coding agent"));
}
#[test]
fn test_apply_patch_template_contains_resolved_tool_names() {
let prompt = render_apply_patch(&default_renderer(), &default_placeholders());
assert!(prompt.contains("todo_write"), "Should contain 'todo_write'");
// apply_patch is hardcoded, not resolved via ${{ tools.by_kind.edit }}
assert!(
prompt.contains("apply_patch"),
"Should contain hardcoded 'apply_patch'"
);
assert!(!prompt.contains("${{"), "No unresolved template variables");
assert!(!prompt.contains("${%"), "No unresolved template blocks");
}
#[test]
fn test_apply_patch_template_plan_absent_omits_planning() {
// Renderer without Plan tool
let tools: HashMap<ToolKind, String> = [
(ToolKind::Read, "read_file".to_string()),
(ToolKind::Edit, "search_replace".to_string()),
(ToolKind::Execute, "run_terminal_cmd".to_string()),
]
.into();
let r = TemplateRenderer::new(tools, HashMap::new());
let prompt = render_apply_patch(&r, &default_placeholders());
assert!(
!prompt.contains("## Planning"),
"Planning section should be omitted when plan tool absent"
);
assert!(
!prompt.contains("update_plan"),
"update_plan references should be omitted"
);
}
#[test]
fn test_apply_patch_template_plan_present_includes_planning() {
let prompt = render_apply_patch(&default_renderer(), &default_placeholders());
assert!(
prompt.contains("## Planning"),
"Planning section should be present when plan tool exists"
);
}
#[test]
fn test_apply_patch_template_with_overridden_tool_names() {
let tools: HashMap<ToolKind, String> = [
(ToolKind::Read, "view_file".to_string()),
(ToolKind::Edit, "some_other_edit".to_string()),
(ToolKind::Execute, "run_terminal_cmd".to_string()),
(ToolKind::Plan, "update_plan".to_string()),
(
ToolKind::BackgroundTaskAction,
"get_task_output".to_string(),
),
]
.into();
let r = TemplateRenderer::new(tools, HashMap::new());
let prompt = render_apply_patch(&r, &default_placeholders());
// apply_patch is hardcoded — NOT affected by Edit tool override
assert!(
prompt.contains("`apply_patch`"),
"apply_patch must remain hardcoded regardless of edit override"
);
assert!(
!prompt.contains("some_other_edit"),
"Edit override must NOT leak into apply-patch prompt"
);
// Plan tool IS resolved via template
assert!(
prompt.contains("`update_plan`"),
"Should use overridden 'update_plan'"
);
}
#[test]
fn test_apply_patch_template_deterministic_across_renders() {
let r = default_renderer();
let p = default_placeholders();
let a = render_apply_patch(&r, &p);
let b = render_apply_patch(&r, &p);
assert_eq!(a, b, "Apply-patch template rendering must be deterministic");
}
#[test]
fn test_subagent_template_deterministic_across_renders() {
let r = default_renderer();
let p = default_placeholders();
let a = render_subagent(&r, &p);
let b = render_subagent(&r, &p);
assert_eq!(a, b, "Subagent template rendering must be deterministic");
}
// ── Task completion discipline ─────────────────────────────────
//
// The `<task_completion_discipline>` block was removed from both
// base and subagent templates. These tests pin the deletion so the
// block doesn't accidentally come back, and so the runtime TodoGate
// doesn't start firing reminders that reference a non-existent
// block.
#[test]
fn task_completion_discipline_block_is_not_rendered() {
let prompt = render_base(&default_renderer(), &default_placeholders());
assert!(
!prompt.contains("<task_completion_discipline>"),
"discipline block was removed from the base template"
);
let subagent = render_subagent(&default_renderer(), &default_placeholders());
assert!(
!subagent.contains("<task_completion_discipline>"),
"discipline block was removed from the subagent template"
);
}
/// Soft byte ceiling shared by both prompt-size budget tests.
/// Forward-budget guard against runaway growth, not a tight target.
const PROMPT_SIZE_SOFT_CEILING_BYTES: usize = 16384;
fn assert_template_size_under(prompt: &str, label: &str) {
assert!(
prompt.len() < PROMPT_SIZE_SOFT_CEILING_BYTES,
"{label} prompt is {} bytes, exceeding soft ceiling of {} bytes",
prompt.len(),
PROMPT_SIZE_SOFT_CEILING_BYTES,
);
}
#[test]
fn test_base_template_size_budget() {
let prompt = render_base(&default_renderer(), &default_placeholders());
assert_template_size_under(&prompt, "base");
}
#[test]
fn test_subagent_template_size_budget() {
let prompt = render_subagent(&default_renderer(), &default_placeholders());
assert_template_size_under(&prompt, "subagent");
}
// ── Guard invariant ─────────────────────────────────────────────
// Every `${{ tools.by_kind.X }}` must sit inside a `${%- if ... %}`
// whose condition requires X (contains `tools.by_kind.X` at a word
// boundary, with no top-level ` or `). If violated, X could render
// as empty string at runtime.
fn word_bounded(hay: &str, needle: &str) -> bool {
let mut s = 0;
while let Some(i) = hay[s..].find(needle) {
let end = s + i + needle.len();
match hay[end..].chars().next() {
None => return true,
Some(c) if !(c.is_alphanumeric() || c == '_') => return true,
_ => s += i + 1,
}
}
false
}
fn guarantees(cond: &str, kind: &str) -> bool {
if word_bounded(cond, &format!("tools.by_kind.{kind}")) && !cond.contains(" or ") {
return true;
}
false
}
fn assert_guards(template: &str, label: &str) {
let bytes = template.as_bytes();
let mut stack: Vec<String> = Vec::new();
let mut errors: Vec<String> = Vec::new();
let mut i = 0;
while i + 2 < bytes.len() {
let three = &bytes[i..i + 3];
if three == b"${%" {
let end = bytes[i + 3..]
.windows(2)
.position(|w| w == b"%}")
.map(|e| i + 3 + e + 2)
.unwrap_or(bytes.len());
let body = std::str::from_utf8(&bytes[i + 3..end - 2])
.unwrap()
.trim_matches(['-', ' ']);
if let Some(c) = body.strip_prefix("if ") {
stack.push(c.trim().into());
} else if let Some(c) = body.strip_prefix("elif ") {
stack.pop();
stack.push(c.trim().into());
} else if body == "else" {
stack.pop();
stack.push("<else>".into());
} else if body == "endif" {
stack.pop();
}
i = end;
} else if three == b"${{" {
let end = bytes[i + 3..]
.windows(2)
.position(|w| w == b"}}")
.map(|e| i + 3 + e + 2)
.unwrap_or(bytes.len());
let body = std::str::from_utf8(&bytes[i + 3..end - 2]).unwrap().trim();
// search_tool and use_tool are always built-in, so they
// never need a guard.
const ALWAYS_BUILTIN: &[&str] = &["search_tool", "use_tool"];
if let Some(kind) = body.strip_prefix("tools.by_kind.")
&& kind.chars().all(|c| c.is_alphanumeric() || c == '_')
&& !ALWAYS_BUILTIN.contains(&kind)
&& !stack.iter().any(|c| guarantees(c, kind))
{
let line = template[..i].lines().count() + 1;
errors.push(format!(
"{label}:{line}: unguarded `${{{{ tools.by_kind.{kind} }}}}` (stack: {stack:?})"
));
}
i = end;
} else {
i += 1;
}
}
assert!(errors.is_empty(), "\n {}", errors.join("\n "));
}
#[test]
fn test_template_vars_are_always_guarded() {
assert_guards(&base_template(), "prompt.md");
assert_guards(&subagent_template(), "subagent_prompt.md");
assert_guards(&apply_patch_template(), "apply_patch_prompt.md");
}
// ── Combination sweep ───────────────────────────────────────────
// Belt-and-braces: renders the base template across tool-kind subsets
// and asserts no raw template tokens leak. The static guard test above
// is the authoritative check; this one just catches syntax drift.
// ── is_non_interactive gating ──────────────────────────────────
// Headless / SDK / stdio / generic-ACP sessions have no human typing
// into a TUI prompt, so the `! <command>` shell-prefix tip and the
// `<user_guide>` TUI pointer are noise. Those sections must drop out
// when `is_non_interactive=true` and remain when it's false.
#[test]
fn interactive_renders_shell_prefix_tip_and_user_guide() {
// The `! <command>` shell-prefix tip was removed from the minimal
// prompt. The <user_guide> block still renders for interactive
// sessions only, so that's what we assert here.
let mut p = default_placeholders();
p["is_non_interactive"] = serde_json::json!(false);
let prompt = render_base(&default_renderer(), &p);
assert!(
prompt.contains("<user_guide>"),
"interactive prompt must keep the <user_guide> block"
);
assert!(
prompt.contains("interactive CLI tool"),
"interactive prompt must declare interactive mode in the header"
);
assert!(
!prompt.contains("autonomous agent"),
"interactive prompt must NOT advertise non-interactive (autonomous) mode"
);
}
#[test]
fn non_interactive_suppresses_shell_prefix_tip_and_user_guide() {
let mut p = default_placeholders();
p["is_non_interactive"] = serde_json::json!(true);
let prompt = render_base(&default_renderer(), &p);
assert!(
!prompt.contains("`! <command>`"),
"non-interactive prompt must suppress the shell-prefix tip"
);
assert!(
!prompt.contains("<user_guide>"),
"non-interactive prompt must suppress the <user_guide> block"
);
assert!(
prompt.contains("autonomous agent"),
"non-interactive prompt must declare autonomous mode in the header"
);
assert!(
!prompt.contains("interactive CLI tool"),
"non-interactive prompt must NOT claim to be the interactive CLI"
);
// Sanity: rest of the template still renders.
assert!(prompt.contains(crate::prompt::context::DEFAULT_SYSTEM_PROMPT_LABEL));
assert!(prompt.contains("user_query"));
}
#[test]
fn test_combination_sweep_no_unresolved_variables() {
let optional = [
ToolKind::Read,
ToolKind::Edit,
ToolKind::Execute,
ToolKind::Search,
ToolKind::List,
ToolKind::Plan,
ToolKind::Skill,
ToolKind::Task,
ToolKind::AskUser,
ToolKind::EnterPlan,
ToolKind::ExitPlan,
ToolKind::BackgroundTaskAction,
ToolKind::Monitor,
ToolKind::MemorySearch,
ToolKind::MemoryGet,
];
let mut subsets: Vec<Vec<ToolKind>> = vec![vec![], optional.to_vec()];
for i in 0..optional.len() {
subsets.push(vec![optional[i]]);
for j in (i + 1)..optional.len() {
subsets.push(vec![optional[i], optional[j]]);
}
}
for memory_enabled in [false, true] {
for subset in &subsets {
let tools: HashMap<ToolKind, String> = subset
.iter()
.map(|k| (*k, format!("{k:?}").to_lowercase()))
.collect();
let r = TemplateRenderer::new(tools, HashMap::new());
let mut p = default_placeholders();
p["memory_enabled"] = serde_json::json!(memory_enabled);
let rendered = r
.render_with_extra(&base_template(), &p)
.unwrap_or_else(|e| {
panic!("render failed: {subset:?} mem={memory_enabled}: {e:?}")
});
assert!(
!rendered.contains("${{") && !rendered.contains("${%"),
"unresolved token in render: {subset:?} mem={memory_enabled}",
);
}
}
}
}
@@ -0,0 +1,376 @@
//! Per-agent first-user-message rendering.
//!
//! Mirrors `prompt::context::PromptContext` but for the first user message
//! (the prefix that contains `<user_info>`, `<git_status>`, optional
//! workspace overview, optional rules / skills / MCP listings).
//!
//! `UserMessageTemplate` selects the rendering strategy:
//! - `Default` -- the legacy Grok Build prefix (built by the shell layer).
//! - `Custom` -- caller-supplied template string (MiniJinja, same delimiters
//! as the system prompt templates).
//!
//! The shell layer gathers session-scoped inputs (cwd, vcs status, rule
//! files, skill registry, MCP servers) and hands them to
//! `UserMessageContext::render`, which dispatches on `template`.
use crate::prompt::agents_md::AgentConfigFile;
use chrono::NaiveDate;
use kigi_tools::bridge::ToolBridge;
use kigi_tools::implementations::skills::types::SkillInfo;
use kigi_tools::types::skill_discovery_tracker::{XmlRenderMode, format_announcement_xml};
use serde::{Deserialize, Serialize};
use std::collections::HashSet;
use std::path::PathBuf;
/// Date format for the `Today's date` field of the user-message preamble
/// (e.g. "Friday Apr 24, 2026"). Any format change is observable to the model.
pub const USER_MESSAGE_DATE_FORMAT: &str = "%A %b %-d, %Y";
/// Per-repo character cap applied to `vcs_status` at render time. The
/// `<git_status>` block has no token budget -- this character cap is the only
/// size control, and it is applied per repo at render, never at gather, so
/// other consumers of the raw status are unaffected.
pub const GIT_STATUS_CHARACTER_LIMIT: usize = 10_000;
/// Trim, drop-if-empty, and cap a VCS status string for the
/// `<git_status>` block.
///
/// Returns `None` when the trimmed status is empty (so the section is dropped
/// and no empty code fence is emitted), otherwise the status capped at
/// [`GIT_STATUS_CHARACTER_LIMIT`] -- snapped back to the last newline -- with
/// the `... (git status truncated)` marker appended.
fn normalize_git_status(status: &str) -> Option<String> {
let status = status.trim();
if status.is_empty() {
return None;
}
if status.len() <= GIT_STATUS_CHARACTER_LIMIT {
return Some(status.to_string());
}
let mut end = GIT_STATUS_CHARACTER_LIMIT;
while !status.is_char_boundary(end) {
end -= 1;
}
let mut truncated = &status[..end];
if let Some(nl) = truncated.rfind('\n')
&& nl > 0
{
truncated = &truncated[..nl];
}
Some(format!("{truncated}\n\n... (git status truncated)"))
}
/// Selects the first-user-message rendering strategy for an agent.
///
/// Built-in variants decrypt the underlying XOR-obfuscated template on demand
/// (obfuscation, not security). Decrypted bytes are zeroed on drop via
/// `Zeroizing`.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum UserMessageTemplate {
/// Legacy Grok Build prefix: `<user_info>` + optional `<git_status>`.
/// Built directly by the shell layer; this
/// renderer returns `None` for `Default` and the caller falls back to
/// its own legacy path.
#[default]
Default,
/// Caller-supplied MiniJinja template string.
Custom(String),
}
impl UserMessageTemplate {
pub fn is_cursor(&self) -> bool {
false
}
}
/// Backward-compatible deserialization: accepts both the new tagged format
/// (`"default"`, `{"custom": "..."}`) and a bare string (treated
/// as `Custom`).
impl<'de> Deserialize<'de> for UserMessageTemplate {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
struct Visitor;
impl<'de> serde::de::Visitor<'de> for Visitor {
type Value = UserMessageTemplate;
fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
f.write_str(r#""default", "cursor", {"custom": "..."}, or a template string"#)
}
fn visit_str<E: serde::de::Error>(self, v: &str) -> Result<Self::Value, E> {
match v {
"default" => Ok(UserMessageTemplate::Default),
other => Ok(UserMessageTemplate::Custom(other.to_owned())),
}
}
fn visit_map<M: serde::de::MapAccess<'de>>(
self,
mut map: M,
) -> Result<Self::Value, M::Error> {
match map.next_key::<String>()? {
Some(ref k) if k == "custom" => {
let val: String = map.next_value()?;
Ok(UserMessageTemplate::Custom(val))
}
Some(other) => Err(serde::de::Error::unknown_field(&other, &["custom"])),
None => Err(serde::de::Error::custom(r#"expected {"custom": "..."}"#)),
}
}
}
deserializer.deserialize_any(Visitor)
}
}
/// One discovered rule file (AGENTS.md / Claude.md / .kigi/rules/*.md).
///
/// Wire-compatible with `AgentConfigFile` -- this type exists so the
/// `UserMessageContext` does not depend on the AGENTS-discovery internals
/// beyond the path/content pair.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RuleEntry {
/// Absolute path of the file (used as the rule `name` attribute).
pub path: String,
/// Raw file body.
pub content: String,
}
impl From<AgentConfigFile> for RuleEntry {
fn from(f: AgentConfigFile) -> Self {
Self {
path: f.file_path,
content: f.content,
}
}
}
/// Connected MCP server metadata.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct McpServerEntry {
pub name: String,
/// Free-form usage instructions a user provided when configuring the
/// server. Surfaced in the `serverUseInstructions` attribute.
pub server_use_instructions: Option<String>,
/// Absolute path to the per-server descriptor folder. Surfaced in
/// the `folderPath` attribute. Compatible models read tool
/// schemas from `<folder_path>/tools/<tool>.json` and resource
/// descriptors from `<folder_path>/resources/<resource>.json` before
/// calling `CallMcpTool`/`FetchMcpResource`. The session is
/// responsible for materializing the descriptor files at this path.
pub folder_path: Option<String>,
}
/// All inputs the templated first user message needs. The shell gathers
/// these once at session start (and again on compaction) and hands the
/// struct to `render`.
#[derive(Debug, Clone)]
pub struct UserMessageContext {
pub template: UserMessageTemplate,
/// Display path -- the path the model sees as the workspace.
pub workspace_path: PathBuf,
/// OS identifier surfaced as the `<user_info>` `OS Version:` value.
///
/// This is `"<kernel> <release>"` (e.g. `"darwin 24.6.0"`,
/// `"linux 6.5.0-..."`) -- not the OS family (`std::env::consts::OS`, e.g.
/// `"macos"`). Producers that don't have a uname-style string available may
/// pass `std::env::consts::OS` as a fallback; callers that need the full
/// string should use `kigi_shell::util::uname::os_kernel_and_release`
/// (or equivalent).
pub os_family: String,
/// `$SHELL` env, basename only -- e.g. "zsh", "bash".
pub shell: String,
/// Git/jj working-tree root, if any.
pub vcs_root: Option<PathBuf>,
/// Pre-fetched VCS status output (caller handles timeouts).
pub vcs_status: Option<String>,
/// Local date captured at session start (or compaction). Formatted
/// inside the renderer using [`USER_MESSAGE_DATE_FORMAT`] so the producer
/// cannot accidentally drift the model-facing date shape.
pub today_local: Option<NaiveDate>,
/// Per-workspace terminals folder, surfaced as
/// `Terminals folder: <path>` in the `<user_info>` block. The
/// shell tool persists each background command's output to a file
/// here (`<terminals_folder>/<numeric-shell-id>.txt`); the model uses
/// this path to read terminal state via the read tool. Optional --
/// when `None`, the line is omitted from the rendered preamble.
pub terminals_folder: Option<PathBuf>,
/// Workspace-scoped rule files (cwd / repo root / optional workspace user dir).
pub workspace_rules: Vec<RuleEntry>,
/// User-scoped rule files (~/.kigi/, ~/.claude/).
pub user_rules: Vec<RuleEntry>,
/// Skill registry snapshot (already deduped). Rendered through the
/// shared budget-tier renderer.
pub skills: Vec<SkillInfo>,
/// Optional listing budget in characters; defaults to the standard
/// 1%-of-context heuristic when None.
pub skill_listing_budget_chars: Option<usize>,
/// Connected MCP servers (alphabetical).
pub mcp_servers: Vec<McpServerEntry>,
/// Absolute path to the per-workspace MCP descriptor root
/// (`~/.kigi/projects/<encoded-cwd>/mcps`). Surfaced in
/// the `<mcp_file_system>` instructions so the model knows where
/// to discover tool/resource schemas. Required when `mcp_servers` is
/// non-empty; ignored otherwise.
pub mcps_root: Option<String>,
/// Client-facing name of the read tool (resolved from `TemplateRenderer`).
/// Used in the skill section's instructional text. Defaults to `"Read"`.
pub read_tool_name: String,
}
/// Typed placeholder bag handed to MiniJinja.
///
/// Field names here must match `${{ … }}` references in any caller-supplied
/// `Custom` template. Keeping this as a typed
/// struct -- rather than a free-form `serde_json::Value` -- means the set
/// of supported placeholders is greppable from one place, every nested
/// shape is enforced by `Serialize`, and rename refactors flow through
/// the compiler instead of silently producing empty strings at render
/// time.
#[derive(Debug, Clone, Serialize)]
struct UserMessagePlaceholders<'a> {
workspace_path: String,
os_family: &'a str,
shell: &'a str,
#[serde(skip_serializing_if = "Option::is_none")]
vcs_root: Option<String>,
/// Owned because the renderer caps/normalizes the raw status via
/// [`normalize_git_status`] before handing it to MiniJinja.
#[serde(skip_serializing_if = "Option::is_none")]
vcs_status: Option<String>,
/// Pre-formatted using [`USER_MESSAGE_DATE_FORMAT`]; `None` is rendered as
/// `null` so the `${% if today_local %}` guard in the template drops
/// the line entirely.
#[serde(skip_serializing_if = "Option::is_none")]
today_local: Option<String>,
/// Pre-rendered as a string so the template can `${% if terminals_folder %}`-guard.
#[serde(skip_serializing_if = "Option::is_none")]
terminals_folder: Option<String>,
has_rules: bool,
workspace_rules: &'a [RuleEntry],
user_rules: &'a [RuleEntry],
/// Pre-rendered budgeted `<agent_skill>` XML rows; the template
/// just substitutes this verbatim. See `render_skill_listing_xml` for
/// why the skill listing is special-cased.
skill_listing: String,
/// Client-facing name of the read tool, used in the skill section's
/// instructional text. Defaults to `"Read"`.
read_tool_name: String,
mcp_servers: &'a [McpServerEntry],
#[serde(skip_serializing_if = "Option::is_none")]
mcps_root: Option<&'a str>,
}
impl UserMessageContext {
/// Build placeholders for MiniJinja rendering.
fn placeholders(&self) -> UserMessagePlaceholders<'_> {
UserMessagePlaceholders {
workspace_path: self.workspace_path.to_string_lossy().into_owned(),
os_family: &self.os_family,
shell: &self.shell,
vcs_root: self
.vcs_root
.as_ref()
.map(|p| p.to_string_lossy().into_owned()),
vcs_status: self.vcs_status.as_deref().and_then(normalize_git_status),
today_local: self
.today_local
.map(|d| d.format(USER_MESSAGE_DATE_FORMAT).to_string()),
terminals_folder: self
.terminals_folder
.as_ref()
.map(|p| p.to_string_lossy().into_owned()),
has_rules: !self.workspace_rules.is_empty() || !self.user_rules.is_empty(),
workspace_rules: &self.workspace_rules,
user_rules: &self.user_rules,
skill_listing: self.render_skill_listing_xml().unwrap_or_default(),
read_tool_name: self.read_tool_name.clone(),
mcp_servers: &self.mcp_servers,
mcps_root: self.mcps_root.as_deref(),
}
}
/// Render the skill list as `<agent_skill>` XML rows.
pub fn render_skill_listing_xml(&self) -> Option<String> {
if self.skills.is_empty() {
return None;
}
let mode = if self.template.is_cursor() {
XmlRenderMode::Verbatim
} else {
XmlRenderMode::Budgeted {
budget_chars: self.skill_listing_budget_chars,
overflow_indicator: true,
}
};
let mut announced = HashSet::new();
format_announcement_xml(&self.skills, &mut announced, None, None, mode)
}
/// Render the first user message.
///
/// Returns `None` for `UserMessageTemplate::Default` -- the caller is
/// responsible for the legacy prefix path. `Custom` dispatches through
/// `ToolBridge::render_prompt` so MiniJinja
/// `${{ tools.by_kind.* }}` references resolve correctly.
pub async fn render(&self, bridge: &ToolBridge) -> Option<String> {
let placeholders = serde_json::to_value(self.placeholders())
.expect("UserMessagePlaceholders serializes infallibly");
let rendered = match &self.template {
UserMessageTemplate::Default => return None,
UserMessageTemplate::Custom(s) => bridge.render_prompt(s, &placeholders).await,
};
rendered.map(|s| s.trim_end().to_string())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn template_override_deserialize_strings() {
let v: UserMessageTemplate = serde_json::from_str(r#""default""#).unwrap();
assert_eq!(v, UserMessageTemplate::Default);
let v: UserMessageTemplate = serde_json::from_str(r#""my custom""#).unwrap();
assert_eq!(v, UserMessageTemplate::Custom("my custom".into()));
}
#[test]
fn template_override_deserialize_custom_map() {
let v: UserMessageTemplate =
serde_json::from_str(r#"{"custom": "my template body"}"#).unwrap();
assert_eq!(v, UserMessageTemplate::Custom("my template body".into()));
}
#[test]
fn template_override_round_trip() {
for original in [
UserMessageTemplate::Default,
UserMessageTemplate::Custom("body".into()),
] {
let json = serde_json::to_string(&original).unwrap();
let loaded: UserMessageTemplate = serde_json::from_str(&json).unwrap();
assert_eq!(original, loaded);
}
}
/// A status under the cap passes through unchanged (trim is a no-op for
/// real `git status --short --branch` output, which starts with `##`).
#[test]
fn normalize_git_status_passthrough_under_limit() {
let status = "## main...origin/main\n M src/app.rs";
assert_eq!(normalize_git_status(status).as_deref(), Some(status));
}
/// Empty / whitespace-only status -> `None` so the section is dropped and
/// no empty fence is emitted.
#[test]
fn normalize_git_status_drops_whitespace_only() {
assert_eq!(normalize_git_status(""), None);
assert_eq!(normalize_git_status(" \n\t "), None);
}
/// A status over the cap is truncated at the last newline before the limit
/// and carries the spec's truncation marker.
#[test]
fn normalize_git_status_truncates_over_limit() {
let mut status = String::from("## main...origin/main\n");
while status.len() <= GIT_STATUS_CHARACTER_LIMIT {
status.push_str(" M src/some/long/path/to/file.rs\n");
}
assert!(status.len() > GIT_STATUS_CHARACTER_LIMIT);
let out = normalize_git_status(&status).expect("non-empty status");
assert!(
out.ends_with("\n\n... (git status truncated)"),
"missing truncation marker: {out}"
);
let body = out
.strip_suffix("\n\n... (git status truncated)")
.expect("marker suffix");
assert!(
body.len() <= GIT_STATUS_CHARACTER_LIMIT,
"body {} exceeds cap {GIT_STATUS_CHARACTER_LIMIT}",
body.len()
);
assert!(status.starts_with(body), "body is not a clean prefix");
assert!(!body.ends_with('\n'), "body should be snapped to last line");
}
}
@@ -0,0 +1,162 @@
//! Optional multi-user workspace helpers for loading per-user agent config.
//!
//! When optional workspace root and user env vars are set and the resolved
//! directory exists, that path can contribute AGENTS.md / rules / skills
//! discovery. Unset env vars are a no-op (typical for standalone installs).
use std::path::PathBuf;
/// If optional workspace env vars are set, returns the user's config directory
/// when the resolved path exists on disk. Unset or missing paths yield `None`.
pub fn optional_workspace_user_dir() -> Option<PathBuf> {
let root = std::env::var("XAI_ROOT").ok()?;
let user = std::env::var("XAI_USER").ok()?;
resolve_workspace_user_dir(&root, &workspace_user_relpath(&user))
}
/// Map `$XAI_USER` to a path relative to the workspace root.
///
/// A bare username is nested one level under `x/` so it cannot collide with an
/// unrelated same-named directory at the workspace root. Values that already
/// contain a path separator are used as-is (explicit relative path).
fn workspace_user_relpath(user: &str) -> String {
if user.contains('/') || user.contains('\\') {
user.to_string()
} else {
format!("x/{user}")
}
}
/// Pure logic: join `root` with a relative `user` path and return it if the
/// directory exists on disk.
///
/// Returns `None` if either argument is empty or the resulting path is not
/// a directory.
///
/// Example: `resolve_workspace_user_dir("/workspace", "users/alice")`
/// → `Some("/workspace/users/alice")` if that directory exists.
pub fn resolve_workspace_user_dir(root: &str, user: &str) -> Option<PathBuf> {
if root.is_empty() || user.is_empty() {
return None;
}
let path = PathBuf::from(root).join(user);
path.is_dir().then_some(path)
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
// ── resolve_workspace_user_dir (pure, no env vars) ───────────────
#[test]
fn resolve_returns_none_for_empty_root() {
assert!(resolve_workspace_user_dir("", "users/someone").is_none());
}
#[test]
fn resolve_returns_none_for_empty_user() {
let tmp = tempfile::tempdir().unwrap();
assert!(resolve_workspace_user_dir(tmp.path().to_str().unwrap(), "").is_none());
}
#[test]
fn resolve_returns_none_for_both_empty() {
assert!(resolve_workspace_user_dir("", "").is_none());
}
#[test]
fn resolve_returns_none_when_dir_does_not_exist() {
let tmp = tempfile::tempdir().unwrap();
assert!(
resolve_workspace_user_dir(tmp.path().to_str().unwrap(), "users/nonexistent").is_none()
);
}
#[test]
fn resolve_returns_path_when_dir_exists() {
let tmp = tempfile::tempdir().unwrap();
let user_dir = tmp.path().join("users").join("testuser");
fs::create_dir_all(&user_dir).unwrap();
let result = resolve_workspace_user_dir(tmp.path().to_str().unwrap(), "users/testuser");
assert_eq!(result, Some(user_dir));
}
#[test]
fn resolve_handles_single_component_user() {
let tmp = tempfile::tempdir().unwrap();
let user_dir = tmp.path().join("alice");
fs::create_dir_all(&user_dir).unwrap();
let result = resolve_workspace_user_dir(tmp.path().to_str().unwrap(), "alice");
assert_eq!(result, Some(user_dir));
}
#[test]
fn resolve_handles_deeply_nested_user() {
let tmp = tempfile::tempdir().unwrap();
let user_dir = tmp.path().join("org").join("team").join("user");
fs::create_dir_all(&user_dir).unwrap();
let result = resolve_workspace_user_dir(tmp.path().to_str().unwrap(), "org/team/user");
assert_eq!(result, Some(user_dir));
}
#[test]
fn resolve_returns_none_when_path_is_file_not_dir() {
let tmp = tempfile::tempdir().unwrap();
let file_path = tmp.path().join("users").join("testuser");
fs::create_dir_all(file_path.parent().unwrap()).unwrap();
fs::write(&file_path, "not a directory").unwrap();
assert!(
resolve_workspace_user_dir(tmp.path().to_str().unwrap(), "users/testuser").is_none()
);
}
#[test]
fn resolve_supports_nested_user_layout_path() {
let tmp = tempfile::tempdir().unwrap();
let user_dir = tmp.path().join("x").join("testuser");
fs::create_dir_all(&user_dir).unwrap();
let result = resolve_workspace_user_dir(tmp.path().to_str().unwrap(), "x/testuser");
assert_eq!(result, Some(user_dir));
}
// ── workspace_user_relpath ───────────────────────────────────────
#[test]
fn bare_username_is_nested_under_x() {
assert_eq!(workspace_user_relpath("alice"), "x/alice");
assert_eq!(workspace_user_relpath("bob"), "x/bob");
}
#[test]
fn multi_segment_user_is_explicit_relative_path() {
assert_eq!(workspace_user_relpath("users/alice"), "users/alice");
assert_eq!(workspace_user_relpath(r"users\alice"), r"users\alice");
}
#[test]
fn bare_username_does_not_resolve_to_same_named_root_dir() {
// Prefer the nested layout even when a same-named directory exists at
// the workspace root.
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
fs::create_dir_all(root.join("alice")).unwrap();
let user_dir = root.join("x").join("alice");
fs::create_dir_all(&user_dir).unwrap();
let rel = workspace_user_relpath("alice");
let resolved = resolve_workspace_user_dir(root.to_str().unwrap(), &rel);
assert_eq!(resolved, Some(user_dir));
assert_ne!(
resolved.as_deref(),
Some(root.join("alice").as_path()),
"must not resolve to a same-named directory at the workspace root"
);
}
}