Publish harness and TUI open-source
initial sync from the monorepo
This commit is contained in:
commit
c68e39f604
2734 changed files with 1437016 additions and 0 deletions
587
crates/codegen/xai-grok-workspace/src/discovery.rs
Normal file
587
crates/codegen/xai-grok-workspace/src/discovery.rs
Normal file
|
|
@ -0,0 +1,587 @@
|
|||
//! Skill, plugin, project-config, and permissions discovery.
|
||||
//!
|
||||
//! Phase 3a delegates to the existing discovery implementations in
|
||||
//! `xai-grok-agent` and `xai-grok-tools` rather than duplicating them.
|
||||
//! The workspace stores configuration on [`WorkspaceShared`] and the
|
||||
//! [`MpscChannel`] methods call these helpers to return real data
|
||||
//! instead of `Value::Null` stubs.
|
||||
//!
|
||||
//! Re-exports [`AgentsMdTracker`] from `xai-grok-tools` for external
|
||||
//! consumers that need per-session project-instruction tracking.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
// Re-export AgentsMdTracker so consumers can reference it via the
|
||||
// workspace crate without a direct xai-grok-tools dependency.
|
||||
pub use xai_grok_tools::types::agents_md_tracker::AgentsMdTracker;
|
||||
|
||||
// Re-export the config types that callers pass into WorkspaceConfig.
|
||||
pub use xai_grok_agent::plugins::discovery::DiscoveryConfig as PluginDiscoveryConfig;
|
||||
pub use xai_grok_agent::plugins::trust::TrustStore as PluginTrustStore;
|
||||
pub use xai_grok_agent::prompt::skills::SkillsConfig;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Skill discovery
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Discover skills visible from the workspace root.
|
||||
///
|
||||
/// Delegates to [`xai_grok_agent::prompt::skills::list_skills`] with
|
||||
/// the workspace's `root_cwd` and the caller-supplied `SkillsConfig`.
|
||||
/// Returns each [`SkillInfo`] serialized to a `serde_json::Value`.
|
||||
///
|
||||
/// The underlying `list_skills` implementation performs filesystem
|
||||
/// I/O (stat + read for each SKILL.md) and does not hold any async
|
||||
/// locks across `.await` points, so contention is not a concern.
|
||||
pub async fn discover_skills(root_cwd: &Path, config: &SkillsConfig) -> Vec<Value> {
|
||||
let cwd_str = root_cwd.to_string_lossy();
|
||||
// Workspace discovery is out of scope for per-vendor compat gating;
|
||||
// use the all-on default to preserve prior behavior.
|
||||
let skills = xai_grok_agent::prompt::skills::list_skills(
|
||||
Some(&cwd_str),
|
||||
config,
|
||||
xai_grok_agent::prompt::skills::CompatConfig::default(),
|
||||
)
|
||||
.await;
|
||||
|
||||
skills
|
||||
.into_iter()
|
||||
.filter_map(|s| match serde_json::to_value(&s) {
|
||||
Ok(v) => Some(v),
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
skill = %s.name,
|
||||
error = %e,
|
||||
"failed to serialize SkillInfo; dropping from response"
|
||||
);
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// AGENTS.md discovery
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Discover project-instruction files (AGENTS.md, Claude.md, rules) from the workspace root up to the git root.
|
||||
pub async fn discover_agents_md(root_cwd: &Path) -> Vec<Value> {
|
||||
let cwd_str = root_cwd.to_string_lossy();
|
||||
let files = xai_grok_agent::prompt::agents_md::read_agents_config_with_paths(
|
||||
&cwd_str,
|
||||
xai_grok_tools::types::compat::CompatConfig::default(),
|
||||
)
|
||||
.await;
|
||||
|
||||
files
|
||||
.into_iter()
|
||||
.map(|mut file| {
|
||||
// Strip rules-file YAML frontmatter so it does not leak as raw YAML (matches grok-build render).
|
||||
if file.file_path.contains("/.grok/rules/")
|
||||
|| file.file_path.contains("/.claude/rules/")
|
||||
{
|
||||
file.content = xai_grok_tools::implementations::skills::skill::extract_skill_body(
|
||||
&file.content,
|
||||
);
|
||||
}
|
||||
file
|
||||
})
|
||||
.filter_map(|file| match serde_json::to_value(&file) {
|
||||
Ok(v) => Some(v),
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
file = %file.file_path,
|
||||
error = %e,
|
||||
"failed to serialize AgentConfigFile; dropping from response"
|
||||
);
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Plugin discovery
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Discover plugins visible from the workspace root.
|
||||
///
|
||||
/// Delegates to [`xai_grok_agent::plugins::discover_plugins`] with
|
||||
/// the workspace's `root_cwd` and the caller-supplied
|
||||
/// [`PluginDiscoveryConfig`] and [`PluginTrustStore`].
|
||||
///
|
||||
/// Since [`DiscoveredPlugin`] does not derive `Serialize`, each
|
||||
/// plugin is converted to a JSON object with the essential fields
|
||||
/// that downstream consumers need (name, scope, root, trusted,
|
||||
/// has_skills, has_hooks, has_mcp).
|
||||
///
|
||||
/// `project_trusted` is the folder-trust verdict for `root_cwd`, threaded into
|
||||
/// discovery to gate Project-scope plugins.
|
||||
pub fn discover_plugins(
|
||||
root_cwd: &Path,
|
||||
config: &PluginDiscoveryConfig,
|
||||
trust_store: &PluginTrustStore,
|
||||
project_trusted: bool,
|
||||
) -> Vec<Value> {
|
||||
let discovered = xai_grok_agent::plugins::discover_plugins(
|
||||
Some(root_cwd),
|
||||
config,
|
||||
trust_store,
|
||||
project_trusted,
|
||||
);
|
||||
|
||||
discovered
|
||||
.into_iter()
|
||||
.map(|dp| {
|
||||
serde_json::json!({
|
||||
"name": dp.manifest.name,
|
||||
"id": dp.id.0,
|
||||
"root": dp.root.to_string_lossy(),
|
||||
"scope": dp.scope.to_string(),
|
||||
"trusted": dp.trusted,
|
||||
"version": dp.manifest.version,
|
||||
"description": dp.manifest.description,
|
||||
"has_skills": !dp.skill_dirs.is_empty(),
|
||||
"has_agents": !dp.agent_dirs.is_empty(),
|
||||
"has_hooks": dp.hooks_path.is_some(),
|
||||
"has_mcp": dp.mcp_config_path.is_some(),
|
||||
"has_lsp": dp.lsp_config_path.is_some(),
|
||||
"conflict": dp.conflict,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Project config
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Load the project config from `<root_cwd>/.grok/config.toml`.
|
||||
///
|
||||
/// Returns `Value::Null` if the file does not exist or cannot be
|
||||
/// parsed. Non-fatal errors are logged.
|
||||
pub fn load_project_config(root_cwd: &Path) -> Value {
|
||||
let config_path = root_cwd.join(".grok").join("config.toml");
|
||||
match xai_grok_config::load_config_file(&config_path) {
|
||||
Ok(toml::Value::Table(ref t)) if t.is_empty() => {
|
||||
// The config loader returns an empty table when the file
|
||||
// does not exist. Normalize to Null for callers.
|
||||
Value::Null
|
||||
}
|
||||
Ok(toml_val) => toml_to_json(&toml_val),
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
path = %config_path.display(),
|
||||
error = %e,
|
||||
"failed to load project config"
|
||||
);
|
||||
Value::Null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert a `toml::Value` to a `serde_json::Value`.
|
||||
///
|
||||
/// TOML's type system is close to JSON's. The main difference is
|
||||
/// TOML's `Datetime` type which maps to a JSON string.
|
||||
fn toml_to_json(v: &toml::Value) -> Value {
|
||||
match v {
|
||||
toml::Value::String(s) => Value::String(s.clone()),
|
||||
toml::Value::Integer(i) => serde_json::json!(i),
|
||||
toml::Value::Float(f) => serde_json::json!(f),
|
||||
toml::Value::Boolean(b) => Value::Bool(*b),
|
||||
toml::Value::Datetime(dt) => Value::String(dt.to_string()),
|
||||
toml::Value::Array(arr) => Value::Array(arr.iter().map(toml_to_json).collect()),
|
||||
toml::Value::Table(table) => {
|
||||
let map: serde_json::Map<String, Value> = table
|
||||
.iter()
|
||||
.map(|(k, v)| (k.clone(), toml_to_json(v)))
|
||||
.collect();
|
||||
Value::Object(map)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Permissions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Load the effective permission configuration for the workspace.
|
||||
///
|
||||
/// Delegates to
|
||||
/// [`resolution::resolve_permissions_with_provenance`] which
|
||||
/// merges rules from requirements.toml, managed-settings.json,
|
||||
/// managed_config.toml, config.toml, and `.claude/settings.json`.
|
||||
///
|
||||
/// Returns a JSON object with `sources`, `loaded` (rule count), and
|
||||
/// `skipped` (unrecognized rules). Returns `Value::Null` if no
|
||||
/// permission sources are configured.
|
||||
pub async fn load_permissions(root_cwd: &Path) -> Value {
|
||||
use crate::permission::resolution;
|
||||
|
||||
let Some(resolved) = resolution::resolve_permissions_with_provenance(root_cwd).await else {
|
||||
return Value::Null;
|
||||
};
|
||||
|
||||
let mut sources: Vec<String> = Vec::new();
|
||||
for s in resolved.sources.iter().map(|s| s.to_string()) {
|
||||
if !sources.contains(&s) {
|
||||
sources.push(s);
|
||||
}
|
||||
}
|
||||
|
||||
let skipped: Vec<Value> = resolved
|
||||
.skipped
|
||||
.iter()
|
||||
.map(|s| {
|
||||
serde_json::json!({
|
||||
"rule": s.rule,
|
||||
"reason": s.reason,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
serde_json::json!({
|
||||
"sources": sources,
|
||||
"loaded": resolved.config.rules.len(),
|
||||
"skipped": skipped,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::fs;
|
||||
|
||||
// ---- Skill discovery tests ----
|
||||
|
||||
// Note: `list_skills` also discovers user-scoped skills from
|
||||
// `~/.grok/skills/`, so on a developer machine the result may be
|
||||
// non-empty even for an empty workspace. Tests below check for
|
||||
// specific skills rather than asserting emptiness.
|
||||
|
||||
#[tokio::test]
|
||||
async fn discover_skills_finds_skill_md() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let skills_dir = tmp.path().join(".grok").join("skills").join("my-skill");
|
||||
fs::create_dir_all(&skills_dir).unwrap();
|
||||
fs::write(
|
||||
skills_dir.join("SKILL.md"),
|
||||
"---\nname: my-skill\ndescription: A test skill\n---\n# Body\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let skills = discover_skills(tmp.path(), &SkillsConfig::default()).await;
|
||||
let found = skills
|
||||
.iter()
|
||||
.find(|s| s["name"].as_str() == Some("my-skill"));
|
||||
assert!(found.is_some(), "should find my-skill");
|
||||
let skill = found.unwrap();
|
||||
assert_eq!(skill["description"].as_str(), Some("A test skill"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn discover_skills_respects_ignore_config() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let skills_dir = tmp.path().join(".grok").join("skills").join("ignored");
|
||||
fs::create_dir_all(&skills_dir).unwrap();
|
||||
fs::write(
|
||||
skills_dir.join("SKILL.md"),
|
||||
"---\nname: ignored\ndescription: Ignored\n---\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let config = SkillsConfig {
|
||||
paths: vec![],
|
||||
ignore: vec![tmp.path().to_string_lossy().to_string()],
|
||||
disabled: vec![],
|
||||
server_skill_dirs: vec![],
|
||||
bundled_skill_dirs: vec![],
|
||||
};
|
||||
let skills = discover_skills(tmp.path(), &config).await;
|
||||
let found = skills.iter().any(|s| s["name"].as_str() == Some("ignored"));
|
||||
assert!(
|
||||
!found,
|
||||
"skill in ignore path should be filtered out of results"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn discover_skills_returns_serialized_values() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let skills_dir = tmp
|
||||
.path()
|
||||
.join(".grok")
|
||||
.join("skills")
|
||||
.join("serialized-check");
|
||||
fs::create_dir_all(&skills_dir).unwrap();
|
||||
fs::write(
|
||||
skills_dir.join("SKILL.md"),
|
||||
"---\nname: serialized-check\ndescription: Test serialization\n---\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let skills = discover_skills(tmp.path(), &SkillsConfig::default()).await;
|
||||
let found = skills
|
||||
.iter()
|
||||
.find(|s| s["name"].as_str() == Some("serialized-check"))
|
||||
.expect("should find serialized-check");
|
||||
// SkillInfo has these required fields when serialized
|
||||
assert!(found["path"].is_string(), "path should be a string");
|
||||
assert!(found["scope"].is_string(), "scope should be serialized");
|
||||
}
|
||||
|
||||
// ---- AGENTS.md discovery tests ----
|
||||
|
||||
#[test]
|
||||
fn agent_config_file_wire_matches_workspace_types_mirror() {
|
||||
// The RPC serializes grok-build's AgentConfigFile and the remote
|
||||
// consumer deserializes the workspace-types mirror; pin the cross-crate
|
||||
// serde shape so a rename/attr drift on either side can't silently
|
||||
// break discovery.
|
||||
let src = xai_grok_agent::prompt::agents_md::AgentConfigFile {
|
||||
file_name: "AGENTS.md".to_string(),
|
||||
file_path: "/repo/AGENTS.md".to_string(),
|
||||
content: "# Instructions\n".to_string(),
|
||||
};
|
||||
let json = serde_json::to_value(&src).unwrap();
|
||||
let mirror: xai_grok_workspace_types::rpc::agents_md::AgentConfigFile =
|
||||
serde_json::from_value(json.clone())
|
||||
.expect("server shape must deserialize into mirror");
|
||||
assert_eq!(mirror.file_name, src.file_name);
|
||||
assert_eq!(mirror.file_path, src.file_path);
|
||||
assert_eq!(mirror.content, src.content);
|
||||
assert_eq!(
|
||||
serde_json::to_value(&mirror).unwrap(),
|
||||
json,
|
||||
"mirror must serialize to the exact shape the server emits"
|
||||
);
|
||||
}
|
||||
|
||||
// Discovery also scans the real `~/.grok`, so fixtures use test-unique names.
|
||||
#[tokio::test]
|
||||
async fn discover_agents_md_strips_rules_frontmatter() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let rules_dir = tmp.path().join(".grok").join("rules");
|
||||
fs::create_dir_all(&rules_dir).unwrap();
|
||||
fs::write(
|
||||
rules_dir.join("xyzzy-discover-agents-md-test.md"),
|
||||
"---\nglobs: [\"*.rs\"]\n---\nUse tabs, not spaces.\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let files = discover_agents_md(tmp.path()).await;
|
||||
let rule = files
|
||||
.iter()
|
||||
.find(|f| {
|
||||
f["file_path"]
|
||||
.as_str()
|
||||
.is_some_and(|p| p.ends_with("/.grok/rules/xyzzy-discover-agents-md-test.md"))
|
||||
})
|
||||
.expect("should discover the rules file");
|
||||
let content = rule["content"].as_str().unwrap();
|
||||
assert!(
|
||||
content.contains("Use tabs, not spaces."),
|
||||
"rule body must survive: {content}"
|
||||
);
|
||||
assert!(
|
||||
!content.contains("globs:"),
|
||||
"YAML frontmatter must be stripped: {content}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn discover_agents_md_keeps_non_rules_frontmatter() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
fs::write(
|
||||
tmp.path().join("AGENTS.md"),
|
||||
"---\nXYZZY_LEADING_DASHES_MARKER\n---\nBody after horizontal rules.\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let files = discover_agents_md(tmp.path()).await;
|
||||
let agents = files
|
||||
.iter()
|
||||
.find(|f| {
|
||||
f["content"]
|
||||
.as_str()
|
||||
.is_some_and(|c| c.contains("XYZZY_LEADING_DASHES_MARKER"))
|
||||
})
|
||||
.expect("should discover the AGENTS.md with its leading dashes intact");
|
||||
let content = agents["content"].as_str().unwrap();
|
||||
assert!(
|
||||
content.starts_with("---\n"),
|
||||
"stripping must be rules-files-only: {content}"
|
||||
);
|
||||
}
|
||||
|
||||
// ---- Plugin discovery tests ----
|
||||
|
||||
// Note: `discover_plugins` also discovers user-scoped plugins
|
||||
// from `~/.grok/plugins/`, so tests check for specific plugins.
|
||||
|
||||
#[test]
|
||||
fn discover_plugins_finds_manifest_plugin() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let plugins_dir = tmp.path().join(".grok").join("plugins").join("test-plugin");
|
||||
fs::create_dir_all(&plugins_dir).unwrap();
|
||||
fs::write(
|
||||
plugins_dir.join("plugin.json"),
|
||||
r#"{"name": "test-plugin"}"#,
|
||||
)
|
||||
.unwrap();
|
||||
// Plugin needs at least one component to be recognized
|
||||
fs::create_dir_all(plugins_dir.join("skills")).unwrap();
|
||||
|
||||
let trust = PluginTrustStore::load_from(tmp.path().join("trust"));
|
||||
let config = PluginDiscoveryConfig::default();
|
||||
let plugins = discover_plugins(tmp.path(), &config, &trust, true);
|
||||
let found = plugins
|
||||
.iter()
|
||||
.find(|p| p["name"].as_str() == Some("test-plugin"));
|
||||
assert!(found.is_some(), "should find test-plugin");
|
||||
let p = found.unwrap();
|
||||
assert_eq!(p["scope"].as_str(), Some("project"));
|
||||
assert_eq!(p["has_skills"].as_bool(), Some(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn discover_plugins_json_has_expected_fields() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let plugins_dir = tmp.path().join(".grok").join("plugins").join("field-test");
|
||||
fs::create_dir_all(plugins_dir.join("skills")).unwrap();
|
||||
fs::write(
|
||||
plugins_dir.join("plugin.json"),
|
||||
r#"{"name": "field-test", "version": "1.0.0", "description": "Test plugin"}"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let trust = PluginTrustStore::load_from(tmp.path().join("trust"));
|
||||
let config = PluginDiscoveryConfig::default();
|
||||
let plugins = discover_plugins(tmp.path(), &config, &trust, true);
|
||||
let p = plugins
|
||||
.iter()
|
||||
.find(|p| p["name"].as_str() == Some("field-test"))
|
||||
.expect("should find field-test");
|
||||
assert_eq!(p["version"].as_str(), Some("1.0.0"));
|
||||
assert_eq!(p["description"].as_str(), Some("Test plugin"));
|
||||
assert!(p["id"].is_string());
|
||||
assert!(p["root"].is_string());
|
||||
assert!(p["trusted"].is_boolean());
|
||||
assert!(p["has_hooks"].is_boolean());
|
||||
assert!(p["has_mcp"].is_boolean());
|
||||
assert!(p["has_lsp"].is_boolean());
|
||||
assert!(p["has_agents"].is_boolean());
|
||||
assert!(p["has_skills"].is_boolean());
|
||||
}
|
||||
|
||||
// ---- Project config tests ----
|
||||
|
||||
#[test]
|
||||
fn load_project_config_missing_file_returns_null() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let config = load_project_config(tmp.path());
|
||||
assert_eq!(config, Value::Null, "missing config → Null");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_project_config_reads_toml_as_json() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let grok_dir = tmp.path().join(".grok");
|
||||
fs::create_dir_all(&grok_dir).unwrap();
|
||||
fs::write(
|
||||
grok_dir.join("config.toml"),
|
||||
"[skills]\npaths = [\"/extra/skills\"]\n\n[plugins]\ndisabled = [\"noisy-plugin\"]\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let config = load_project_config(tmp.path());
|
||||
assert!(config.is_object(), "parsed config should be an object");
|
||||
assert!(
|
||||
config["skills"]["paths"].is_array(),
|
||||
"skills.paths should be an array"
|
||||
);
|
||||
assert_eq!(config["skills"]["paths"][0].as_str(), Some("/extra/skills"));
|
||||
assert_eq!(
|
||||
config["plugins"]["disabled"][0].as_str(),
|
||||
Some("noisy-plugin")
|
||||
);
|
||||
}
|
||||
|
||||
// ---- toml_to_json tests ----
|
||||
|
||||
#[test]
|
||||
fn toml_to_json_basic_types() {
|
||||
let toml_val: toml::Value =
|
||||
toml::from_str("s = \"hello\"\ni = 42\nf = 3.14\nb = true\n").unwrap();
|
||||
let json = toml_to_json(&toml_val);
|
||||
assert_eq!(json["s"], "hello");
|
||||
assert_eq!(json["i"], 42);
|
||||
assert_eq!(json["b"], true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn toml_to_json_nested_table() {
|
||||
let toml_val: toml::Value = toml::from_str("[section]\nkey = \"value\"\n").unwrap();
|
||||
let json = toml_to_json(&toml_val);
|
||||
assert_eq!(json["section"]["key"], "value");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn toml_to_json_array() {
|
||||
let toml_val: toml::Value = toml::from_str("items = [1, 2, 3]\n").unwrap();
|
||||
let json = toml_to_json(&toml_val);
|
||||
assert_eq!(json["items"][0], 1);
|
||||
assert_eq!(json["items"][2], 3);
|
||||
}
|
||||
|
||||
// ---- Permissions tests ----
|
||||
|
||||
// Note: `resolve_permissions_with_provenance` checks system-managed
|
||||
// settings and requirements.toml from the global config, so on a
|
||||
// developer machine with Grok installed it may return non-Null even
|
||||
// for a temp directory. Both branches assert a concrete condition.
|
||||
|
||||
#[tokio::test]
|
||||
async fn load_permissions_returns_valid_json() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let result = load_permissions(tmp.path()).await;
|
||||
// Result is either Null (no sources) or an object with
|
||||
// sources, loaded, and skipped fields. Both branches assert
|
||||
// a definite pass criterion.
|
||||
if result.is_null() {
|
||||
// No permission sources on this machine — Null is correct.
|
||||
assert_eq!(result, Value::Null, "expected Null for empty workspace");
|
||||
} else {
|
||||
assert!(result["sources"].is_array(), "sources should be an array");
|
||||
assert!(result["loaded"].is_number(), "loaded should be a number");
|
||||
assert!(result["skipped"].is_array(), "skipped should be an array");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn load_permissions_with_settings_file_returns_object() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
// Create a minimal .claude/settings.json with a permission rule
|
||||
// so the test always exercises the non-null path.
|
||||
let claude_dir = tmp.path().join(".claude");
|
||||
fs::create_dir_all(&claude_dir).unwrap();
|
||||
fs::write(
|
||||
claude_dir.join("settings.json"),
|
||||
r#"{"permissions":{"allow":["Bash(git status)"]}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let result = load_permissions(tmp.path()).await;
|
||||
assert!(result.is_object(), "should return an object, got {result}");
|
||||
assert!(result["sources"].is_array(), "sources should be an array");
|
||||
assert!(result["loaded"].is_number(), "loaded should be a number");
|
||||
assert!(result["skipped"].is_array(), "skipped should be an array");
|
||||
assert!(
|
||||
result["loaded"].as_u64().unwrap_or(0) >= 1,
|
||||
"should have at least one loaded rule"
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue