use crate::scope::ActiveScopes;
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::{Path, PathBuf};
use tracing;
fn icm_state_path(state_dir: &Path) -> PathBuf {
state_dir.join("icm.json")
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
struct IcmMemory {
tags: Vec<String>,
bundles: Vec<String>,
}
pub fn generate_context_chunk(active: &ActiveScopes, bundles: &[String]) -> String {
let tags_str = if active.tags.is_empty() {
"(none)".to_string()
} else {
active
.tags
.iter()
.map(|t| format!("`{}`", t))
.collect::<Vec<_>>()
.join(", ")
};
let bundles_str = if bundles.is_empty() {
"(none)".to_string()
} else {
bundles
.iter()
.map(|b| format!("`{}`", b))
.collect::<Vec<_>>()
.join(", ")
};
let mut chunk = format!(
"## llmenv context\n\
Active tags: {}\n\
Bundles: {}\n\n\
Store scope-specific memory under keyword `llmenv-tag:<tag>` (per tag) \
or `llmenv-bundle:<bundle>` (per bundle) so it is retrievable across \
projects. On each turn, llmenv auto-recalls memory under these tags' \
`llmenv-tag:<tag>` and bundles' `llmenv-bundle:<bundle>` keywords \
across all projects.",
tags_str, bundles_str
);
for scope in &active.scopes {
if scope.kind == "project"
&& let Some(name) = &scope.name
{
chunk.push_str("\n\n**Project:** ");
chunk.push_str(name);
if let Some(desc) = &scope.description {
chunk.push_str(" — ");
chunk.push_str(desc);
}
}
}
chunk
}
pub fn store_tag_memory(active: &ActiveScopes, bundles: &[String]) -> anyhow::Result<()> {
let state_dir = crate::paths::state_dir()?;
fs::create_dir_all(&state_dir)?;
let memory = IcmMemory {
tags: active.tags.iter().cloned().collect::<Vec<_>>(),
bundles: bundles.to_vec(),
};
write_memory(&icm_state_path(&state_dir), &memory)?;
tracing::debug!(
"stored ICM tag memory: tags={}, bundles={}",
memory.tags.join(","),
memory.bundles.join(",")
);
Ok(())
}
fn write_memory(path: &Path, memory: &IcmMemory) -> anyhow::Result<()> {
let json = serde_json::to_string(memory)?;
crate::paths::write_owner_only_atomic(path, json.as_bytes())?;
Ok(())
}
#[cfg(test)]
fn read_memory(path: &Path) -> anyhow::Result<IcmMemory> {
let body = fs::read_to_string(path)?;
Ok(serde_json::from_str(&body)?)
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod tests {
use super::*;
use std::collections::BTreeSet;
#[test]
fn test_context_chunk_includes_tags() {
let mut tags = BTreeSet::new();
tags.insert("work-vpn".to_string());
tags.insert("rust".to_string());
let active = ActiveScopes {
scopes: vec![],
tags,
};
let chunk = generate_context_chunk(&active, &[]);
assert!(chunk.contains("work-vpn"));
assert!(chunk.contains("rust"));
assert!(chunk.contains("llmenv-tag"));
}
#[test]
fn test_context_chunk_handles_no_tags() {
let active = ActiveScopes::default();
let chunk = generate_context_chunk(&active, &[]);
assert!(chunk.contains("(none)"));
}
#[test]
fn test_context_chunk_includes_bundles() {
let active = ActiveScopes::default();
let bundles = vec!["bundle1".to_string(), "bundle2".to_string()];
let chunk = generate_context_chunk(&active, &bundles);
assert!(chunk.contains("bundle1"));
assert!(chunk.contains("bundle2"));
assert!(
chunk.contains("llmenv-bundle:"),
"chunk must document llmenv-bundle keyword format for agents"
);
}
#[test]
fn test_context_chunk_includes_project_description() {
use crate::scope::ActiveScope;
let active = ActiveScopes {
scopes: vec![ActiveScope {
id: "myproj".into(),
kind: "project",
tags: vec![],
project_root: Some(std::path::PathBuf::from("/tmp/myproj")),
enable_bundles: vec![],
name: Some("MyProject".into()),
description: Some("A test project".into()),
unknown_fields: vec![],
}],
tags: BTreeSet::new(),
};
let chunk = generate_context_chunk(&active, &[]);
assert!(chunk.contains("MyProject"), "name must appear");
assert!(chunk.contains("A test project"), "description must appear");
}
#[test]
fn test_context_chunk_omits_description_when_absent() {
use crate::scope::ActiveScope;
let active = ActiveScopes {
scopes: vec![ActiveScope {
id: "myproj".into(),
kind: "project",
tags: vec![],
project_root: Some(std::path::PathBuf::from("/tmp/myproj")),
enable_bundles: vec![],
name: Some("MyProject".into()),
description: None,
unknown_fields: vec![],
}],
tags: BTreeSet::new(),
};
let chunk = generate_context_chunk(&active, &[]);
assert!(chunk.contains("MyProject"));
assert!(
!chunk.contains("MyProject —"),
"no separator when description absent"
);
}
#[test]
fn test_store_tag_memory_succeeds() {
let mut tags = BTreeSet::new();
tags.insert("work".to_string());
tags.insert("rust".to_string());
let active = ActiveScopes {
scopes: vec![],
tags,
};
let bundles = vec!["bundle1".to_string(), "bundle2".to_string()];
let result = store_tag_memory(&active, &bundles);
assert!(result.is_ok());
}
use proptest::prelude::*;
proptest! {
#[test]
fn icm_memory_serde_roundtrip(
tags in proptest::collection::vec(r"\PC{0,30}", 0..10),
bundles in proptest::collection::vec(r"\PC{0,30}", 0..10),
) {
let memory = IcmMemory { tags, bundles };
let json = serde_json::to_string(&memory).expect("serialize");
let decoded: IcmMemory = serde_json::from_str(&json).expect("deserialize");
prop_assert_eq!(memory, decoded);
}
#[test]
fn icm_memory_empty_roundtrip(_unit in any::<()>()) {
let memory = IcmMemory { tags: vec![], bundles: vec![] };
let json = serde_json::to_string(&memory).expect("serialize");
let decoded: IcmMemory = serde_json::from_str(&json).expect("deserialize");
prop_assert_eq!(memory, decoded);
}
#[test]
fn store_recall_filesystem_roundtrip(
tags in proptest::collection::vec(r"[\w \-:,]{0,30}", 0..8),
bundles in proptest::collection::vec(r"[\w \-:,]{0,30}", 0..8),
) {
let temp = tempfile::TempDir::new().expect("tempdir");
let path = temp.path().join("icm.json");
let memory = IcmMemory { tags, bundles };
write_memory(&path, &memory).expect("write");
let recalled = read_memory(&path).expect("read");
prop_assert_eq!(memory, recalled);
}
#[test]
fn store_recall_idempotent(
tags in proptest::collection::vec(r"[a-zA-Z0-9_-]{1,20}", 0..5),
) {
let temp = tempfile::TempDir::new().expect("tempdir");
let path = temp.path().join("icm.json");
let memory = IcmMemory { tags, bundles: vec![] };
for _ in 0..3 {
write_memory(&path, &memory).expect("write");
let recalled = read_memory(&path).expect("read");
prop_assert_eq!(&memory, &recalled);
}
}
#[test]
fn store_writes_owner_only_permissions(
tags in proptest::collection::vec(r"[a-z]{1,10}", 0..3),
) {
use std::os::unix::fs::PermissionsExt;
let temp = tempfile::TempDir::new().expect("tempdir");
let path = temp.path().join("icm.json");
let memory = IcmMemory { tags, bundles: vec![] };
write_memory(&path, &memory).expect("write");
let mode = fs::metadata(&path).expect("metadata").permissions().mode();
prop_assert_eq!(mode & 0o077, 0, "group/other bits set: {:o}", mode);
}
}
}