use std::collections::{HashMap, HashSet};
use std::fs;
use std::ops::{Deref, DerefMut};
use std::path::{Path, PathBuf};
use std::process::Command;
use std::time::Instant;
use crate::engine::error::CoreError;
use crate::engine::flow::{expand_direction_names, load_direction, load_step, Direction, Step};
use crate::engine::worktrees::{main_repo_root, wave_name_from_worktree_and_main};
use crate::lfd::types::RepoId;
use once_cell::sync::Lazy;
use serde::{Deserialize, Serialize};
use tiktoken_rs::CoreBPE;
use tracing::{debug, warn};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum DocumentSource {
Step,
Direction,
Diff,
RepoDoc,
Scratch,
Wave,
WaveMemory,
Summary,
Area,
Clipboard,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RelatedRepoContext {
pub repo_id: RepoId,
pub path: PathBuf,
}
#[derive(Debug, Clone)]
pub struct Document {
pub path: String,
pub content: String,
pub source: DocumentSource,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DocumentEntry {
pub path: String,
pub source: DocumentSource,
pub tokens: usize,
}
pub const DEFAULT_CONTEXT_BUDGET: usize = 75_000;
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub enum DiffTier {
UnifiedDiff,
StatOnly,
#[default]
None,
}
#[derive(Debug, Clone, Default)]
pub struct ContextBreakdown {
pub source_tokens: HashMap<DocumentSource, usize>,
pub source_counts: HashMap<DocumentSource, usize>,
pub documents: Vec<DocumentEntry>,
pub system_tokens: usize,
pub step_name: Option<String>,
pub direction_names: Vec<String>,
pub diff_tier: DiffTier,
pub diff_file_count: usize,
pub area_name: Option<String>,
pub area_doc_count: usize,
pub has_clipboard: bool,
pub wave_name: Option<String>,
}
impl ContextBreakdown {
pub fn total(&self) -> usize {
self.system_tokens + self.source_tokens.values().sum::<usize>()
}
pub fn source_tokens(&self, source: DocumentSource) -> usize {
self.source_tokens.get(&source).copied().unwrap_or(0)
}
pub fn source_count(&self, source: DocumentSource) -> usize {
self.source_counts.get(&source).copied().unwrap_or(0)
}
fn add_source_tokens(&mut self, source: DocumentSource, tokens: usize) {
*self.source_tokens.entry(source).or_insert(0) += tokens;
}
fn subtract_source_tokens(&mut self, source: DocumentSource, tokens: usize) {
let entry = self.source_tokens.entry(source).or_insert(0);
*entry = entry.saturating_sub(tokens);
}
fn set_source_tokens(&mut self, source: DocumentSource, tokens: usize) {
self.source_tokens.insert(source, tokens);
}
}
fn push_doc_entries(entries: &mut Vec<DocumentEntry>, docs: &[Document], tokens: &[usize]) {
for (doc, &t) in docs.iter().zip(tokens) {
entries.push(DocumentEntry {
path: doc.path.clone(),
source: doc.source,
tokens: t,
});
}
}
fn build_document_entries(
components: &PromptComponents,
diff_file_tokens: &[usize],
summary_tokens: &[usize],
wave_memory_tokens: Option<usize>,
doc_tokens: &[usize],
area_doc_tokens: &[usize],
) -> Vec<DocumentEntry> {
let mut entries = Vec::new();
push_doc_entries(&mut entries, &components.diff_files, diff_file_tokens);
push_doc_entries(&mut entries, &components.summaries, summary_tokens);
if let (Some(doc), Some(tokens)) = (&components.wave_memory, wave_memory_tokens) {
entries.push(DocumentEntry {
path: doc.path.clone(),
source: doc.source,
tokens,
});
}
push_doc_entries(&mut entries, &components.docs, doc_tokens);
push_doc_entries(&mut entries, &components.area_docs, area_doc_tokens);
entries
}
#[derive(Debug, Clone, Default)]
pub struct GatherSpec {
pub sources: Vec<DocumentSource>,
pub repo_root: PathBuf,
pub files: Vec<String>,
pub area: Option<String>,
pub wave: Option<String>,
pub related_repos: Vec<RelatedRepoContext>,
}
impl GatherSpec {
fn includes(&self, source: DocumentSource) -> bool {
self.sources.contains(&source)
}
fn include_source(&mut self, source: DocumentSource) {
if !self.includes(source) {
self.sources.push(source);
}
}
fn normalize(&mut self) {
if self.wave.is_some() && self.includes(DocumentSource::RepoDoc) {
self.include_source(DocumentSource::Wave);
self.include_source(DocumentSource::WaveMemory);
}
if self.area.is_some() {
self.include_source(DocumentSource::Area);
}
if !self.files.is_empty() {
self.include_source(DocumentSource::Diff);
}
}
}
pub fn default_gather_sources(
include_repo_docs: bool,
include_diff: bool,
include_clipboard: bool,
) -> Vec<DocumentSource> {
let mut sources = Vec::new();
if include_repo_docs {
sources.push(DocumentSource::RepoDoc);
}
if include_diff {
sources.push(DocumentSource::Diff);
}
if include_clipboard {
sources.push(DocumentSource::Clipboard);
}
sources
}
#[derive(Debug, Clone, Default)]
pub struct GatherContextOpts {
pub repo_root: PathBuf,
pub step: Option<String>,
pub message: Option<String>,
pub surface: Surface,
pub directions: Vec<String>,
pub files: Vec<String>,
pub sources: Vec<DocumentSource>,
pub area: Option<String>,
pub wave: Option<String>,
pub related_repos: Vec<RelatedRepoContext>,
}
impl GatherContextOpts {
pub fn gather_spec(&self) -> GatherSpec {
GatherSpec {
sources: self.sources.clone(),
repo_root: self.repo_root.clone(),
files: self.files.clone(),
area: self.area.clone(),
wave: self.wave.clone(),
related_repos: self.related_repos.clone(),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum PromptFormatMode {
#[default]
Full,
Context,
Task,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[non_exhaustive]
#[serde(rename_all = "snake_case")]
pub enum Surface {
Cli,
ConcertoMac,
ConcertoIphone,
#[default]
#[serde(other)]
Headless,
}
impl Surface {
pub fn is_interactive(self) -> bool {
!matches!(self, Self::Headless)
}
pub fn instructions(self) -> &'static str {
use crate::engine::builtins;
match self {
Self::Headless => builtins::SURFACE_HEADLESS,
Self::Cli => builtins::SURFACE_CLI,
Self::ConcertoMac => builtins::SURFACE_CONCERTO_MAC,
Self::ConcertoIphone => builtins::SURFACE_CONCERTO_IPHONE,
}
}
}
impl std::str::FromStr for Surface {
type Err = std::convert::Infallible;
fn from_str(value: &str) -> Result<Self, Self::Err> {
let surface = match value {
"cli" => Self::Cli,
"concerto_mac" => Self::ConcertoMac,
"concerto_iphone" => Self::ConcertoIphone,
_ => Self::Headless,
};
Ok(surface)
}
}
#[derive(Debug, Clone, Default)]
pub struct PromptComponents {
pub surface: Surface,
pub docs: Vec<Document>,
pub diff: Option<String>,
pub diff_files: Vec<Document>,
pub step: Option<Step>,
pub repo_root: String,
pub clipboard: Option<String>,
pub directions: Vec<Direction>,
pub summaries: Vec<Document>,
pub wave_memory: Option<Document>,
pub wave: Option<String>,
pub loopflow_doc: Option<String>,
pub voice_doc: Option<String>,
pub message: Option<String>,
pub diff_tier: DiffTier,
pub diff_file_count: usize,
pub area_docs: Vec<Document>,
pub area: Option<String>,
}
#[derive(Debug, Clone, Default)]
pub struct GatheredContext(pub PromptComponents);
impl GatheredContext {
pub fn into_components(self) -> PromptComponents {
self.0
}
pub fn components(&self) -> &PromptComponents {
&self.0
}
pub fn components_mut(&mut self) -> &mut PromptComponents {
&mut self.0
}
}
impl Deref for GatheredContext {
type Target = PromptComponents;
fn deref(&self) -> &Self::Target {
self.components()
}
}
impl DerefMut for GatheredContext {
fn deref_mut(&mut self) -> &mut Self::Target {
self.components_mut()
}
}
#[derive(Debug, Clone, Default)]
pub struct BudgetedContext(pub PromptComponents, pub ContextBreakdown);
impl BudgetedContext {
pub fn components(&self) -> &PromptComponents {
&self.0
}
pub fn breakdown(&self) -> &ContextBreakdown {
&self.1
}
pub fn into_parts(self) -> (PromptComponents, ContextBreakdown) {
(self.0, self.1)
}
}
impl Deref for BudgetedContext {
type Target = PromptComponents;
fn deref(&self) -> &Self::Target {
self.components()
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct RenderedPrompt(pub String);
impl RenderedPrompt {
pub fn as_str(&self) -> &str {
&self.0
}
pub fn into_string(self) -> String {
self.0
}
}
impl std::fmt::Display for RenderedPrompt {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, Clone)]
struct TokenCacheEntry {
mtime_secs: u64,
size: u64,
tokens: usize,
}
pub fn count_tokens(text: &str) -> usize {
static BPE: Lazy<Option<CoreBPE>> = Lazy::new(|| tiktoken_rs::cl100k_base().ok());
if let Some(bpe) = BPE.as_ref() {
return std::cmp::max(bpe.encode_ordinary(text).len(), 1);
}
std::cmp::max(text.len() / 3, 1)
}
fn cached_doc_tokens(
doc: &Document,
repo_root: &Path,
cache: &mut HashMap<String, TokenCacheEntry>,
) -> usize {
let abs_path = repo_root.join(&doc.path);
let key = abs_path.to_string_lossy().to_string();
let Ok(metadata) = fs::metadata(&abs_path) else {
return count_tokens(&doc.content);
};
let size = metadata.len();
let mtime_secs = metadata
.modified()
.ok()
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.map(|d| d.as_secs())
.unwrap_or(0);
if let Some(entry) = cache.get(&key) {
if entry.size == size && entry.mtime_secs == mtime_secs {
return entry.tokens;
}
}
let tokens = count_tokens(&doc.content);
cache.insert(
key,
TokenCacheEntry {
mtime_secs,
size,
tokens,
},
);
tokens
}
pub fn trim_context_with_breakdown(context: GatheredContext, max_tokens: usize) -> BudgetedContext {
let mut components = context.into_components();
let repo_root = PathBuf::from(&components.repo_root);
static TOKEN_CACHE: Lazy<std::sync::Mutex<HashMap<String, TokenCacheEntry>>> =
Lazy::new(|| std::sync::Mutex::new(HashMap::new()));
let mut cache = TOKEN_CACHE
.lock()
.map(|guard| guard.clone())
.unwrap_or_default();
let mut breakdown = ContextBreakdown::default();
if let Some(ref doc) = components.loopflow_doc {
breakdown.system_tokens = count_tokens(doc);
}
if let Some(ref step) = components.step {
if let Some(ref content) = step.content {
breakdown.add_source_tokens(DocumentSource::Step, count_tokens(content));
}
breakdown.step_name = Some(step.name.clone());
}
for dir in &components.directions {
breakdown.add_source_tokens(DocumentSource::Direction, count_tokens(&dir.content));
breakdown.direction_names.push(dir.name.clone());
}
let diff_string_tokens = if let Some(ref diff) = components.diff {
let tokens = count_tokens(diff);
breakdown.add_source_tokens(DocumentSource::Diff, tokens);
tokens
} else {
0
};
let mut diff_file_tokens: Vec<usize> = components
.diff_files
.iter()
.map(|doc| cached_doc_tokens(doc, &repo_root, &mut cache))
.collect();
breakdown.add_source_tokens(DocumentSource::Diff, diff_file_tokens.iter().sum::<usize>());
breakdown.diff_file_count = components.diff_file_count;
breakdown.diff_tier = components.diff_tier.clone();
let mut summary_tokens: Vec<usize> = components
.summaries
.iter()
.map(|doc| cached_doc_tokens(doc, &repo_root, &mut cache))
.collect();
let wave_memory_tokens = components
.wave_memory
.as_ref()
.map(|doc| cached_doc_tokens(doc, &repo_root, &mut cache))
.unwrap_or(0);
let mut doc_tokens: Vec<usize> = components
.docs
.iter()
.map(|doc| cached_doc_tokens(doc, &repo_root, &mut cache))
.collect();
breakdown.add_source_tokens(
DocumentSource::Summary,
summary_tokens.iter().sum::<usize>(),
);
breakdown.add_source_tokens(DocumentSource::WaveMemory, wave_memory_tokens);
for (doc, tokens) in components.docs.iter().zip(doc_tokens.iter().copied()) {
breakdown.add_source_tokens(doc.source, tokens);
}
let mut area_doc_tokens: Vec<usize> = components
.area_docs
.iter()
.map(|doc| cached_doc_tokens(doc, &repo_root, &mut cache))
.collect();
breakdown.add_source_tokens(DocumentSource::Area, area_doc_tokens.iter().sum::<usize>());
breakdown.area_name = components.area.clone();
breakdown.area_doc_count = components.area_docs.len();
if let Some(ref clip) = components.clipboard {
breakdown.set_source_tokens(DocumentSource::Clipboard, count_tokens(clip));
}
breakdown.has_clipboard = components.clipboard.is_some();
breakdown.wave_name = components.wave.clone();
let mut total = breakdown.total();
if total > max_tokens {
while total > max_tokens && !components.area_docs.is_empty() {
components.area_docs.pop();
if let Some(tokens) = area_doc_tokens.pop() {
breakdown.subtract_source_tokens(DocumentSource::Area, tokens);
total = total.saturating_sub(tokens);
breakdown.area_doc_count = breakdown.area_doc_count.saturating_sub(1);
}
}
if total > max_tokens && components.wave_memory.is_some() {
components.wave_memory = None;
breakdown.subtract_source_tokens(DocumentSource::WaveMemory, wave_memory_tokens);
total = total.saturating_sub(wave_memory_tokens);
}
while total > max_tokens && !components.summaries.is_empty() {
components.summaries.pop();
if let Some(tokens) = summary_tokens.pop() {
breakdown.subtract_source_tokens(DocumentSource::Summary, tokens);
total = total.saturating_sub(tokens);
}
}
while total > max_tokens && !components.docs.is_empty() {
let removed = components.docs.pop();
if let Some(tokens) = doc_tokens.pop() {
if let Some(doc) = removed {
breakdown.subtract_source_tokens(doc.source, tokens);
}
total = total.saturating_sub(tokens);
}
}
while total > max_tokens && !components.diff_files.is_empty() {
components.diff_files.pop();
if let Some(tokens) = diff_file_tokens.pop() {
breakdown.subtract_source_tokens(DocumentSource::Diff, tokens);
total = total.saturating_sub(tokens);
breakdown.diff_file_count = breakdown.diff_file_count.saturating_sub(1);
}
}
if total > max_tokens && components.diff.is_some() {
components.diff = None;
breakdown.diff_tier = DiffTier::None;
breakdown.subtract_source_tokens(DocumentSource::Diff, diff_string_tokens);
total = total.saturating_sub(diff_string_tokens);
}
if total > max_tokens && components.clipboard.is_some() {
components.clipboard = None;
breakdown.set_source_tokens(DocumentSource::Clipboard, 0);
}
}
let wave_memory_tokens = if components.wave_memory.is_some() {
Some(wave_memory_tokens)
} else {
None
};
breakdown.documents = build_document_entries(
&components,
&diff_file_tokens,
&summary_tokens,
wave_memory_tokens,
&doc_tokens,
&area_doc_tokens,
);
let mut source_counts: HashMap<DocumentSource, usize> = HashMap::new();
for entry in &breakdown.documents {
*source_counts.entry(entry.source).or_insert(0) += 1;
}
let has_diff = components.diff.is_some() || !components.diff_files.is_empty();
if has_diff && breakdown.diff_file_count > 0 {
source_counts.insert(DocumentSource::Diff, breakdown.diff_file_count);
}
breakdown.source_counts = source_counts;
if breakdown.documents.len() > 100 {
warn!(
document_count = breakdown.documents.len(),
area = components.area.as_deref().unwrap_or_default(),
"context breakdown has more than 100 documents; consider narrowing area or diff scope"
);
}
components.diff_file_count = components.diff_files.len();
if let Ok(mut guard) = TOKEN_CACHE.lock() {
*guard = cache;
}
BudgetedContext(components, breakdown)
}
pub fn gather_context(opts: &GatherContextOpts) -> Result<GatheredContext, CoreError> {
let start = Instant::now();
let repo_root = &opts.repo_root;
let step_start = Instant::now();
let step = match &opts.step {
Some(step_name) => Some(load_step(step_name, repo_root)?),
None => None,
};
debug!(elapsed_ms = step_start.elapsed().as_millis(), "loaded step");
let directions_start = Instant::now();
let mut direction_names = Vec::new();
if let Some(ref step) = step {
direction_names.extend(step.directions.clone());
}
direction_names.extend(opts.directions.clone());
let expanded_names = expand_direction_names(&direction_names, repo_root);
let mut directions = Vec::new();
for name in &expanded_names {
directions.push(load_direction(name, repo_root)?);
}
debug!(
elapsed_ms = directions_start.elapsed().as_millis(),
count = directions.len(),
"loaded directions"
);
let mut spec = opts.gather_spec();
let include_branch_diff = opts.sources.contains(&DocumentSource::Diff);
spec.normalize();
let docs_start = Instant::now();
let gathered_docs = gather_documents(&spec)?;
debug!(
elapsed_ms = docs_start.elapsed().as_millis(),
count = gathered_docs.len(),
"gathered documents"
);
let mut docs = Vec::new();
let mut summaries = Vec::new();
let mut wave_memory = None;
let mut diff_files = Vec::new();
let mut area_docs = Vec::new();
for doc in gathered_docs {
match doc.source {
DocumentSource::RepoDoc | DocumentSource::Scratch | DocumentSource::Wave => {
docs.push(doc)
}
DocumentSource::Summary => summaries.push(doc),
DocumentSource::WaveMemory => wave_memory = Some(doc),
DocumentSource::Area => area_docs.push(doc),
DocumentSource::Diff => diff_files.push(doc),
DocumentSource::Step | DocumentSource::Direction | DocumentSource::Clipboard => {}
}
}
dedup_documents(&mut diff_files);
let diff_start = Instant::now();
let (diff, diff_tier, diff_file_count) = if include_branch_diff {
gather_diff_tiered(repo_root)?
} else {
(None, DiffTier::None, 0)
};
debug!(
elapsed_ms = diff_start.elapsed().as_millis(),
?diff_tier,
has_diff = diff.is_some(),
"gathered diff"
);
let clipboard_start = Instant::now();
let clipboard = if spec.includes(DocumentSource::Clipboard) {
read_clipboard()
} else {
None
};
debug!(
elapsed_ms = clipboard_start.elapsed().as_millis(),
has_clipboard = clipboard.is_some(),
"read clipboard"
);
let loopflow_doc = Some(crate::engine::builtins::LOOPFLOW_DOC.to_string());
let voice_doc = resolve_voice_doc(repo_root);
debug!(elapsed_ms = start.elapsed().as_millis(), "gathered context");
Ok(GatheredContext(PromptComponents {
surface: opts.surface,
docs,
diff,
diff_files,
step,
repo_root: repo_root.to_string_lossy().to_string(),
clipboard,
directions,
summaries,
wave_memory,
wave: opts.wave.clone(),
loopflow_doc,
voice_doc,
message: opts.message.clone(),
diff_tier,
diff_file_count,
area_docs,
area: opts.area.clone(),
}))
}
pub fn gather_documents(spec: &GatherSpec) -> Result<Vec<Document>, CoreError> {
let mut docs = Vec::new();
if spec.includes(DocumentSource::RepoDoc) {
docs.extend(gather_scratch_docs(&spec.repo_root)?);
}
if spec.includes(DocumentSource::Wave) {
docs.extend(gather_wave_docs(&spec.repo_root, spec.wave.as_deref())?);
}
if spec.includes(DocumentSource::WaveMemory) {
if let Some(doc) = gather_wave_memory_doc(&spec.repo_root, spec.wave.as_deref())? {
docs.push(doc);
}
}
if spec.includes(DocumentSource::RepoDoc) {
docs.extend(gather_repo_root_docs(&spec.repo_root)?);
}
if spec.includes(DocumentSource::Area) {
if let Some(ref area) = spec.area {
match resolve_area(area, &spec.related_repos) {
ResolvedArea::Local { area } => {
docs.extend(gather_area_docs(&spec.repo_root, area));
}
ResolvedArea::CrossRepo { related, area } => {
match gather_repo_root_docs(&related.path) {
Ok(related_docs) => {
for mut doc in related_docs {
doc.path = format!("[{}] {}", related.repo_id, doc.path);
docs.push(doc);
}
}
Err(err) => {
warn!(
repo_id = %related.repo_id,
path = %related.path.display(),
error = %err,
"failed to gather related repo root docs"
);
}
}
let area_docs = gather_area_docs(&related.path, area);
for mut doc in area_docs {
doc.path = format!("[{}] {}", related.repo_id, doc.path);
docs.push(doc);
}
}
}
}
}
if spec.includes(DocumentSource::Diff) && !spec.files.is_empty() {
docs.extend(gather_files(&spec.repo_root, &spec.files)?);
}
Ok(docs)
}
fn resolve_voice_doc(repo_root: &Path) -> Option<String> {
if let Some(home) = dirs::home_dir() {
let user_voice = home.join(".lf/voice.md");
if let Ok(content) = std::fs::read_to_string(&user_voice) {
return Some(content);
}
}
let repo_voice = repo_root.join(".lf/voice.md");
if let Ok(content) = std::fs::read_to_string(&repo_voice) {
return Some(content);
}
Some(crate::engine::builtins::VOICE_DOC.to_string())
}
fn gather_scratch_docs(repo_root: &Path) -> Result<Vec<Document>, CoreError> {
let mut docs = Vec::new();
let scratch_dir = repo_root.join("scratch");
if scratch_dir.is_dir() {
gather_md_files(&scratch_dir, &mut docs, DocumentSource::Scratch)?;
}
Ok(docs)
}
fn gather_wave_docs(repo_root: &Path, wave: Option<&str>) -> Result<Vec<Document>, CoreError> {
let mut docs = Vec::new();
if let Some(wave_name) = wave {
let wave_dir = repo_root.join("wave").join(wave_name);
if wave_dir.is_dir() {
let readme = wave_dir.join("README.md");
if readme.is_file() {
if let Ok(content) = fs::read_to_string(&readme) {
docs.push(Document {
path: format!("wave/{}/README.md", wave_name),
content,
source: DocumentSource::Wave,
});
}
}
let mut entries: Vec<_> = fs::read_dir(&wave_dir)?
.filter_map(|e| e.ok())
.filter(|e| {
let path = e.path();
path.is_file()
&& path.extension().map(|ext| ext == "md").unwrap_or(false)
&& path.file_name().map(|n| n != "README.md").unwrap_or(false)
&& path.file_name().map(|n| n != "MEMORY.md").unwrap_or(false)
})
.collect();
entries.sort_by_key(|e| e.path());
for entry in entries {
let path = entry.path();
if let Ok(content) = fs::read_to_string(&path) {
docs.push(Document {
path: format!(
"wave/{}/{}",
wave_name,
path.file_name().unwrap_or_default().to_string_lossy()
),
content,
source: DocumentSource::Wave,
});
}
}
}
}
Ok(docs)
}
fn gather_wave_memory_doc(
repo_root: &Path,
wave: Option<&str>,
) -> Result<Option<Document>, CoreError> {
let Some(wave_name) = wave else {
return Ok(None);
};
let memory_path = repo_root.join("wave").join(wave_name).join("MEMORY.md");
if !memory_path.is_file() {
return Ok(None);
}
let Ok(content) = fs::read_to_string(&memory_path) else {
return Ok(None);
};
Ok(Some(Document {
path: format!("wave/{wave_name}/MEMORY.md"),
content,
source: DocumentSource::WaveMemory,
}))
}
fn gather_repo_root_docs(repo_root: &Path) -> Result<Vec<Document>, CoreError> {
let mut docs = Vec::new();
let mut entries: Vec<_> = fs::read_dir(repo_root)?
.filter_map(|e| e.ok())
.filter(|e| {
let path = e.path();
path.is_file() && path.extension().map(|ext| ext == "md").unwrap_or(false)
})
.collect();
entries.sort_by_key(|e| e.path());
for entry in entries {
let path = entry.path();
if let Ok(content) = fs::read_to_string(&path) {
docs.push(Document {
path: path
.file_name()
.unwrap_or_default()
.to_string_lossy()
.to_string(),
content,
source: DocumentSource::RepoDoc,
});
}
}
Ok(docs)
}
enum ResolvedArea<'a> {
Local {
area: &'a str,
},
CrossRepo {
related: &'a RelatedRepoContext,
area: &'a str,
},
}
fn resolve_area<'a>(area: &'a str, related_repos: &'a [RelatedRepoContext]) -> ResolvedArea<'a> {
if let Some((repo_name, area_path)) = area.split_once(':') {
if !repo_name.is_empty() {
let matches: Vec<_> = related_repos
.iter()
.filter(|r| r.repo_id.name() == repo_name)
.collect();
match matches.len() {
1 => {
let resolved_area = if area_path.is_empty() { "." } else { area_path };
return ResolvedArea::CrossRepo {
related: matches[0],
area: resolved_area,
};
}
0 => {
warn!(
repo_name = repo_name,
"no related repo named '{}', treating as local area", repo_name
);
}
_ => {
warn!(
repo_name = repo_name,
"ambiguous: multiple related repos named '{}', treating as local area",
repo_name
);
}
}
}
}
ResolvedArea::Local { area }
}
fn gather_area_docs(repo_root: &Path, area: &str) -> Vec<Document> {
let area_path = Path::new(area);
let mut ancestors = Vec::new();
if !area_path.as_os_str().is_empty() {
ancestors.push(area_path.to_path_buf());
}
let mut current = area_path.to_path_buf();
while let Some(parent) = current.parent() {
if parent.as_os_str().is_empty() {
break;
}
ancestors.push(parent.to_path_buf());
current = parent.to_path_buf();
}
ancestors.reverse();
let mut docs = Vec::new();
let mut seen = HashSet::new();
for ancestor in &ancestors {
let abs_dir = repo_root.join(ancestor);
if !abs_dir.is_dir() {
continue;
}
let mut entries: Vec<_> = match fs::read_dir(&abs_dir) {
Ok(entries) => entries
.filter_map(|e| e.ok())
.filter(|e| {
let path = e.path();
path.is_file() && path.extension().map(|ext| ext == "md").unwrap_or(false)
})
.collect(),
Err(_) => continue,
};
entries.sort_by_key(|e| e.path());
for entry in entries {
let path = entry.path();
let rel_path = path
.strip_prefix(repo_root)
.unwrap_or(&path)
.to_string_lossy()
.to_string();
if seen.contains(&rel_path) {
continue;
}
if let Ok(content) = fs::read_to_string(&path) {
seen.insert(rel_path.clone());
docs.push(Document {
path: rel_path,
content,
source: DocumentSource::Area,
});
}
}
}
let area_abs = repo_root.join(area_path);
if area_abs.is_dir() {
let mut descendant_docs = Vec::new();
gather_area_descendants(&area_abs, repo_root, &mut descendant_docs, &mut seen);
descendant_docs.sort_by(|a, b| {
let depth_a = a.path.matches('/').count();
let depth_b = b.path.matches('/').count();
depth_a.cmp(&depth_b).then_with(|| a.path.cmp(&b.path))
});
descendant_docs.truncate(100);
docs.extend(descendant_docs);
}
docs
}
fn gather_area_descendants(
dir: &Path,
repo_root: &Path,
docs: &mut Vec<Document>,
seen: &mut HashSet<String>,
) {
let entries = match fs::read_dir(dir) {
Ok(entries) => entries,
Err(_) => return,
};
let mut sorted: Vec<_> = entries.filter_map(|e| e.ok()).collect();
sorted.sort_by_key(|entry| entry.path());
for entry in sorted {
let path = entry.path();
if path.is_dir() {
gather_area_descendants(&path, repo_root, docs, seen);
continue;
}
if !path.extension().map(|ext| ext == "md").unwrap_or(false) {
continue;
}
let rel_path = path
.strip_prefix(repo_root)
.unwrap_or(&path)
.to_string_lossy()
.to_string();
if seen.contains(&rel_path) {
continue;
}
if let Ok(content) = fs::read_to_string(&path) {
seen.insert(rel_path.clone());
docs.push(Document {
path: rel_path,
content,
source: DocumentSource::Area,
});
}
}
}
fn gather_md_files(
dir: &Path,
docs: &mut Vec<Document>,
source: DocumentSource,
) -> Result<(), CoreError> {
if !dir.is_dir() {
return Ok(());
}
for entry in fs::read_dir(dir)? {
let entry = entry?;
let path = entry.path();
if path.is_dir() {
gather_md_files(&path, docs, source)?;
} else if path.extension().map(|e| e == "md").unwrap_or(false) {
if let Ok(content) = fs::read_to_string(&path) {
docs.push(Document {
path: path
.strip_prefix(dir.parent().unwrap_or(dir))
.unwrap_or(&path)
.to_string_lossy()
.to_string(),
content,
source,
});
}
}
}
Ok(())
}
fn gather_files(repo_root: &Path, files: &[String]) -> Result<Vec<Document>, CoreError> {
if files.is_empty() {
return Ok(Vec::new());
}
let gitignore = build_gitignore(repo_root);
let mut seen = HashSet::new();
let mut docs = Vec::new();
for file in files {
let path = match resolve_path(repo_root, file) {
Some(path) => path,
None => continue,
};
if path.is_dir() {
gather_dir_files(repo_root, &path, &gitignore, &mut seen, &mut docs)?;
continue;
}
if !path.is_file() {
continue;
}
if should_exclude(repo_root, &path, &gitignore) {
continue;
}
let canonical = path.canonicalize().unwrap_or(path.clone());
if !seen.insert(canonical) {
continue;
}
let content = match read_text_file(&path) {
Some(content) => content,
None => continue,
};
let rel_path = path
.strip_prefix(repo_root)
.unwrap_or(&path)
.to_string_lossy()
.to_string();
docs.push(Document {
path: rel_path,
content,
source: DocumentSource::Diff,
});
}
Ok(docs)
}
fn gather_dir_files(
repo_root: &Path,
dir: &Path,
gitignore: &ignore::gitignore::Gitignore,
seen: &mut HashSet<PathBuf>,
docs: &mut Vec<Document>,
) -> Result<(), CoreError> {
let walker = ignore::WalkBuilder::new(dir)
.hidden(true)
.standard_filters(true)
.build();
let mut entries: Vec<_> = walker
.filter_map(|e| e.ok())
.filter(|e| e.path().is_file())
.collect();
entries.sort_by(|a, b| a.path().cmp(b.path()));
for entry in entries {
let path = entry.into_path();
if should_exclude(repo_root, &path, gitignore) {
continue;
}
let canonical = path.canonicalize().unwrap_or(path.clone());
if !seen.insert(canonical) {
continue;
}
let content = match read_text_file(&path) {
Some(content) => content,
None => continue,
};
let rel_path = path
.strip_prefix(repo_root)
.unwrap_or(&path)
.to_string_lossy()
.to_string();
docs.push(Document {
path: rel_path,
content,
source: DocumentSource::Diff,
});
}
Ok(())
}
#[cfg(test)]
fn gather_all_text_files(repo_root: &Path) -> Result<Vec<Document>, CoreError> {
let gitignore = build_gitignore(repo_root);
let mut docs = Vec::new();
let walker = ignore::WalkBuilder::new(repo_root)
.hidden(false)
.standard_filters(true)
.build();
for entry in walker {
let entry = match entry {
Ok(entry) => entry,
Err(_) => continue,
};
let path = entry.path();
if !path.is_file() {
continue;
}
if should_exclude(repo_root, path, &gitignore) {
continue;
}
if let Some(content) = read_text_file(path) {
let rel_path = path
.strip_prefix(repo_root)
.unwrap_or(path)
.to_string_lossy()
.to_string();
docs.push(Document {
path: rel_path,
content,
source: DocumentSource::Diff,
});
}
}
Ok(docs)
}
const DIFF_TIER_THRESHOLD: usize = 15_000;
const DIFF_MAX_FILES_FOR_FULL: usize = 20;
const DIFF_MAX_LINES_FOR_FULL: usize = 800;
fn gather_diff_tiered(repo_root: &Path) -> Result<(Option<String>, DiffTier, usize), CoreError> {
let branch_output = Command::new("git")
.args(["branch", "--show-current"])
.current_dir(repo_root)
.output()?;
let branch = String::from_utf8_lossy(&branch_output.stdout)
.trim()
.to_string();
if branch.is_empty() || branch == "main" {
return Ok((None, DiffTier::None, 0));
}
let base_branch =
crate::engine::git::get_default_branch(repo_root).unwrap_or("main".to_string());
let diff_ref = format!("origin/{}...HEAD", base_branch);
let name_output = Command::new("git")
.args(["diff", "--name-only", &diff_ref])
.current_dir(repo_root)
.output()?;
let committed_count = if name_output.status.success() {
String::from_utf8_lossy(&name_output.stdout)
.lines()
.filter(|l| !l.trim().is_empty())
.count()
} else {
0
};
let (diff_ref, file_count) = if committed_count > 0 {
(diff_ref, committed_count)
} else {
let head_output = Command::new("git")
.args(["diff", "--name-only", "HEAD"])
.current_dir(repo_root)
.output()?;
let head_count = if head_output.status.success() {
String::from_utf8_lossy(&head_output.stdout)
.lines()
.filter(|l| !l.trim().is_empty())
.count()
} else {
0
};
("HEAD".to_string(), head_count)
};
if file_count == 0 {
return Ok((None, DiffTier::None, 0));
}
let shortstat_output = Command::new("git")
.args(["diff", "--shortstat", &diff_ref])
.current_dir(repo_root)
.output()?;
let shortstat = if shortstat_output.status.success() {
String::from_utf8_lossy(&shortstat_output.stdout).to_string()
} else {
String::new()
};
let total_lines = parse_shortstat_total_lines(&shortstat).unwrap_or(0);
let stat_output = Command::new("git")
.args(["diff", "--stat", &diff_ref])
.current_dir(repo_root)
.output()?;
let stat = if stat_output.status.success() {
String::from_utf8_lossy(&stat_output.stdout).to_string()
} else {
String::new()
};
let allow_full_diff =
file_count <= DIFF_MAX_FILES_FOR_FULL && total_lines <= DIFF_MAX_LINES_FOR_FULL;
if allow_full_diff {
let diff_output = Command::new("git")
.args(["diff", &diff_ref])
.current_dir(repo_root)
.output()?;
if diff_output.status.success() {
let diff = String::from_utf8_lossy(&diff_output.stdout).to_string();
if !diff.trim().is_empty() && count_tokens(&diff) < DIFF_TIER_THRESHOLD {
return Ok((Some(diff), DiffTier::UnifiedDiff, file_count));
}
}
}
if !stat.trim().is_empty() {
return Ok((Some(stat), DiffTier::StatOnly, file_count));
}
Ok((None, DiffTier::None, file_count))
}
fn parse_shortstat_total_lines(output: &str) -> Option<usize> {
let numbers: Vec<usize> = output
.split_whitespace()
.filter_map(|token| token.parse::<usize>().ok())
.collect();
if numbers.len() <= 1 {
return Some(0);
}
Some(numbers[1..].iter().sum())
}
fn read_clipboard() -> Option<String> {
crate::engine::clipboard::read()
}
fn build_gitignore(repo_root: &Path) -> ignore::gitignore::Gitignore {
let mut builder = ignore::gitignore::GitignoreBuilder::new(repo_root);
let _ = builder.add(repo_root.join(".gitignore"));
match builder.build() {
Ok(gitignore) => gitignore,
Err(_) => ignore::gitignore::Gitignore::empty(),
}
}
fn resolve_path(repo_root: &Path, file: &str) -> Option<PathBuf> {
let path = Path::new(file);
let joined = if path.is_absolute() {
path.to_path_buf()
} else {
repo_root.join(path)
};
if joined.exists() {
Some(joined)
} else if file.starts_with("./") {
let trimmed = file.trim_start_matches("./");
let joined = repo_root.join(trimmed);
if joined.exists() {
return Some(joined);
}
None
} else {
None
}
}
const ALWAYS_EXCLUDE: &[&str] = &[
"Cargo.lock",
"uv.lock",
"package-lock.json",
"yarn.lock",
"pnpm-lock.yaml",
"Gemfile.lock",
"poetry.lock",
"composer.lock",
];
fn should_exclude(repo_root: &Path, path: &Path, gitignore: &ignore::gitignore::Gitignore) -> bool {
if path
.components()
.any(|component| component.as_os_str() == ".lf")
{
return true;
}
if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
if ALWAYS_EXCLUDE.contains(&name) {
return true;
}
}
let relative = path.strip_prefix(repo_root).unwrap_or(path);
gitignore
.matched_path_or_any_parents(relative, path.is_dir())
.is_ignore()
}
const AGENT_NATIVE_FILES: &[&str] = &["CLAUDE.md", "AGENTS.md", "GEMINI.md"];
pub fn drop_native_instruction_docs(components: &mut PromptComponents, repo_root: &Path) {
let canonical_paths: Vec<_> = AGENT_NATIVE_FILES
.iter()
.filter_map(|f| fs::canonicalize(repo_root.join(f)).ok())
.collect();
components.docs.retain(|doc| {
let name = Path::new(&doc.path)
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("");
if AGENT_NATIVE_FILES.contains(&name) {
return false;
}
let doc_path = repo_root.join(&doc.path);
if let Ok(doc_canon) = fs::canonicalize(&doc_path) {
if canonical_paths.contains(&doc_canon) {
return false;
}
}
true
});
}
fn read_text_file(path: &Path) -> Option<String> {
let bytes = fs::read(path).ok()?;
if bytes.is_empty() {
return Some(String::new());
}
if bytes.contains(&0) {
return None;
}
String::from_utf8(bytes).ok()
}
fn dedup_documents(docs: &mut Vec<Document>) {
let mut seen = HashSet::new();
docs.retain(|doc| seen.insert(doc.path.clone()));
}
fn ensure_gitignore_entry(repo_root: &Path, entry: &str) -> Result<(), CoreError> {
let gitignore_path = repo_root.join(".gitignore");
let content = if gitignore_path.exists() {
fs::read_to_string(&gitignore_path)?
} else {
String::new()
};
let entry_line = entry.trim();
let already_present = content.lines().any(|line| line.trim() == entry_line);
if !already_present {
let mut new_content = content;
if !new_content.is_empty() && !new_content.ends_with('\n') {
new_content.push('\n');
}
new_content.push_str(entry_line);
new_content.push('\n');
fs::write(&gitignore_path, new_content)?;
}
Ok(())
}
fn format_direction_tags(directions: &[Direction]) -> String {
if directions.len() == 1 {
let d = &directions[0];
format!(
"<lf:direction:{}>\n{}\n</lf:direction:{}>",
d.name, d.content, d.name
)
} else {
let parts: Vec<String> = directions
.iter()
.map(|d| {
format!(
"<lf:direction:{}>\n{}\n</lf:direction:{}>",
d.name, d.content, d.name
)
})
.collect();
format!("<lf:directions>\n{}\n</lf:directions>", parts.join("\n"))
}
}
pub fn format_system_sections(components: &PromptComponents) -> Vec<String> {
let mut parts = Vec::new();
if let Some(ref doc) = components.loopflow_doc {
parts.push(format!("<lf:loopflow>\n{}\n</lf:loopflow>", doc));
}
if components.loopflow_doc.is_some() {
parts.push(format!(
"<lf:rlm>\n{}\n</lf:rlm>",
crate::engine::builtins::RLM_DOC
));
}
if components.surface.is_interactive() {
if let Some(ref voice) = components.voice_doc {
parts.push(format!("<lf:voice>\n{}\n</lf:voice>", voice));
}
}
parts.push(components.surface.instructions().to_string());
parts
}
pub fn format_content_sections(components: &PromptComponents) -> Vec<String> {
let mut parts = Vec::new();
if let Some(ref wave) = components.wave {
let memory_path = format!("wave/{wave}/MEMORY.md");
let memory_content = components
.wave_memory
.as_ref()
.map(|doc| {
format!(
"<lf:memory path=\"{}\">\n{}\n</lf:memory>",
memory_path, doc.content
)
})
.unwrap_or_else(|| {
format!(
"<lf:memory path=\"{}\">\n(no memory yet)\n</lf:memory>",
memory_path
)
});
parts.push(format!(
"<lf:wave name=\"{}\">\n\
You are building toward the {} program of work.\n\
Wave context is included in docs below.\n\n\
## Wave memory\n\n\
Persistent memory at {}. Budget: ~25k tokens.\n\
Read it before you start. Update it aggressively — correct stale entries,\n\
add observations, remove what's wrong. Don't wait until the end of your session.\n\n\
Suggested sections — Patterns, Preferences, Learnings — but add your own as needed.\n\
- Patterns: codebase conventions, architecture, how things connect\n\
- Preferences: user workflow, tool choices, communication norms\n\
- Learnings: what worked, what failed, surprises\n\n\
What belongs elsewhere:\n\
- architectural decisions → wave docs or area docs\n\
- design rationale → scratch/ or wave plan\n\
- session-specific notes → nowhere (let them die)\n\n\
How to update:\n\
- Edit within sections. Don't rewrite the whole file.\n\
- Correct or remove entries that are wrong or stale.\n\
- Use absolute dates, not \"today\" or \"recently\".\n\
- When a section grows large, promote stable entries to wave/area docs and trim.\n\n\
{}\n\
</lf:wave>",
wave, wave, memory_path, memory_content
));
}
if !components.docs.is_empty() {
let doc_parts: Vec<String> = components
.docs
.iter()
.map(|doc| {
let name = Path::new(&doc.path)
.file_stem()
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_else(|| doc.path.clone());
format!("<lf:{}>\n{}\n</lf:{}>", name, doc.content, name)
})
.collect();
let docs_body = doc_parts.join("\n\n");
parts.push(format!(
"Repository documentation. Follow STYLE carefully. \
May include design artifacts (scratch/).\n\n\
<lf:docs>\n{}\n</lf:docs>",
docs_body
));
}
if !components.area_docs.is_empty() {
let area_label = components.area.as_deref().unwrap_or("area");
let area_parts: Vec<String> = components
.area_docs
.iter()
.map(|doc| {
let name = Path::new(&doc.path)
.file_stem()
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_else(|| doc.path.clone());
format!("<lf:{}>\n{}\n</lf:{}>", name, doc.content, name)
})
.collect();
let area_body = area_parts.join("\n\n");
parts.push(format!(
"Area docs for `{}`. Architectural context from ancestor and descendant directories.\n\n\
<lf:area>\n{}\n</lf:area>",
area_label, area_body
));
}
parts
}
fn format_step_tag(step: &Step) -> String {
if let Some(ref content) = step.content {
format!(
"<lf:step:{}>\n{}\n</lf:step:{}>",
step.name, content, step.name
)
} else {
format!("<lf:step:{}>\n</lf:step:{}>", step.name, step.name)
}
}
fn format_reference_sections(components: &PromptComponents) -> Vec<String> {
let mut parts = format_system_sections(components);
parts.extend(format_content_sections(components));
if !components.summaries.is_empty() {
let summary_parts: Vec<String> = components
.summaries
.iter()
.map(|s| {
format!(
"<lf:summary path=\"{}\">\n{}\n</lf:summary>",
s.path, s.content
)
})
.collect();
let summaries_body = summary_parts.join("\n\n");
parts.push(format!(
"Pre-generated codebase summaries.\n\n\
<lf:summaries>\n{}\n</lf:summaries>",
summaries_body
));
}
if components.diff.is_some() || !components.diff_files.is_empty() {
let mut diff_parts = Vec::new();
if let Some(ref diff) = components.diff {
diff_parts.push(format!("<lf:diff>\n{}\n</lf:diff>", diff));
}
if !components.diff_files.is_empty() {
let files_content = format_files(&components.diff_files);
diff_parts.push(files_content);
}
parts.push(format!(
"Changes on this branch (diff against main).\n\n{}",
diff_parts.join("\n\n")
));
}
parts
}
pub fn format_prompt(mode: PromptFormatMode, components: &BudgetedContext) -> RenderedPrompt {
let rendered = match mode {
PromptFormatMode::Full => {
let mut parts = format_reference_sections(components);
if !components.directions.is_empty() {
let label = if components.directions.len() == 1 {
"Direction"
} else {
"Directions"
};
parts.push(format!(
"{label} for this work.\n\n{}",
format_direction_tags(&components.directions)
));
}
if let Some(ref clipboard) = components.clipboard {
parts.push(format!(
"Content from clipboard.\n\n\
<lf:clipboard>\n{}\n</lf:clipboard>",
clipboard
));
}
if let Some(ref step) = components.step {
parts.push(format!("The step.\n\n{}", format_step_tag(step)));
}
if let Some(ref message) = components.message {
parts.push(format!(
"Additional instructions from user.\n\n\
<lf:message>\n{}\n</lf:message>",
message
));
}
parts.join("\n\n")
}
PromptFormatMode::Context => {
let mut parts = format_reference_sections(components);
if !components.directions.is_empty() {
parts.push(format_direction_tags(&components.directions));
}
if let Some(ref clipboard) = components.clipboard {
parts.push(format!(
"Content from clipboard.\n\n\
<lf:clipboard>\n{}\n</lf:clipboard>",
clipboard
));
}
parts.join("\n\n")
}
PromptFormatMode::Task => {
let mut parts = Vec::new();
if let Some(ref step) = components.step {
parts.push(format_step_tag(step));
}
if let Some(ref message) = components.message {
parts.push(message.clone());
}
parts.join("\n\n")
}
};
RenderedPrompt(rendered)
}
pub fn format_context_prompt(components: &BudgetedContext) -> String {
format_prompt(PromptFormatMode::Context, components).into_string()
}
pub fn format_task_prompt(components: &BudgetedContext) -> String {
format_prompt(PromptFormatMode::Task, components).into_string()
}
pub fn format_claude_system_prompt(components: &BudgetedContext) -> String {
let mut parts = format_system_sections(components);
if !components.directions.is_empty() {
parts.push(format_direction_tags(&components.directions));
}
parts.join("\n\n")
}
pub fn format_claude_task_prompt(components: &BudgetedContext) -> String {
let mut parts = format_content_sections(components);
if let Some(ref clipboard) = components.clipboard {
parts.push(format!(
"Content from clipboard.\n\n\
<lf:clipboard>\n{}\n</lf:clipboard>",
clipboard
));
}
if let Some(ref step) = components.step {
parts.push(format_step_tag(step));
}
if let Some(ref message) = components.message {
parts.push(message.clone());
}
parts.join("\n\n")
}
pub fn write_prompt_log(
repo_root: &Path,
prompt: &str,
step_name: &str,
flow_parents: Option<&[String]>,
) -> Result<PathBuf, CoreError> {
let prompts_dir = repo_root.join(".lf/prompts");
fs::create_dir_all(&prompts_dir)?;
ensure_gitignore_entry(repo_root, ".lf/prompts/")?;
let timestamp = chrono::Local::now().format("%Y%m%d-%H%M%S");
let safe_step = step_name.replace('/', ".");
let name_part = match flow_parents {
Some(parents) if !parents.is_empty() => {
format!("{}.{}", parents.join("."), safe_step)
}
_ => safe_step,
};
let filename = format!("{}-{}.md", timestamp, name_part);
let path = prompts_dir.join(&filename);
fs::write(&path, prompt)?;
if let Some(durable_dir) = durable_log_dir(repo_root) {
let _ = fs::create_dir_all(&durable_dir);
let _ = fs::write(durable_dir.join(&filename), prompt);
}
Ok(path)
}
pub fn durable_log_dir(repo_root: &Path) -> Option<PathBuf> {
let home = dirs::home_dir()?;
let (repo_name, worktree_name) = match main_repo_root(repo_root) {
Ok(main_root) => {
let main_name = main_root.file_name()?.to_str()?.to_string();
let wt_name = wave_name_from_worktree_and_main(repo_root, &main_root)
.unwrap_or_else(|| "main".to_string());
(main_name, wt_name)
}
Err(_) => {
let name = repo_root.file_name()?.to_str()?.to_string();
(name, "main".to_string())
}
};
Some(home.join(".lf/logs").join(repo_name).join(worktree_name))
}
fn format_files(docs: &[Document]) -> String {
let mut parts = Vec::new();
parts.push(
"Reference files for this task. Includes parent documentation for context.".to_string(),
);
parts.push("<lf:files>".to_string());
for doc in docs {
parts.push(format!(
"<lf:file path=\"{}\">\n{}\n</lf:file>",
doc.path, doc.content
));
}
parts.push("</lf:files>".to_string());
parts.join("\n")
}
#[cfg(test)]
mod tests {
use super::*;
use crate::engine::flow::{Direction, Step};
use std::path::{Path, PathBuf};
fn init_repo() -> tempfile::TempDir {
let dir = tempfile::tempdir().expect("tempdir");
std::fs::create_dir_all(dir.path().join(".lf/steps")).expect("create steps");
std::fs::create_dir_all(dir.path().join(".lf/directions")).expect("create directions");
dir
}
fn write_file(repo: &Path, path: &str, content: &str) {
let full_path = repo.join(path);
if let Some(parent) = full_path.parent() {
std::fs::create_dir_all(parent).expect("create parent");
}
std::fs::write(full_path, content).expect("write file");
}
fn write_binary(repo: &Path, path: &str, content: &[u8]) {
let full_path = repo.join(path);
if let Some(parent) = full_path.parent() {
std::fs::create_dir_all(parent).expect("create parent");
}
std::fs::write(full_path, content).expect("write binary file");
}
fn budget_context(components: PromptComponents) -> BudgetedContext {
trim_context_with_breakdown(GatheredContext(components), usize::MAX)
}
fn render_full_prompt(components: PromptComponents) -> String {
format_prompt(PromptFormatMode::Full, &budget_context(components)).into_string()
}
#[test]
fn count_tokens_basic() {
let text = "Hello, world! This is a test.";
let tokens = count_tokens(text);
assert!(tokens > 0);
assert!(tokens < text.len()); }
#[test]
fn count_tokens_empty() {
assert_eq!(count_tokens(""), 1);
}
#[test]
fn trim_context_under_budget() {
let mut components = PromptComponents::default();
components.docs.push(Document {
path: "test.md".to_string(),
content: "Short content".to_string(),
source: DocumentSource::RepoDoc,
});
let trimmed = trim_context_with_breakdown(GatheredContext(components.clone()), 1000000);
assert_eq!(trimmed.components().docs.len(), 1);
}
#[test]
fn trim_context_breakdown_includes_documents_and_source_counts() {
let components = PromptComponents {
docs: vec![
Document {
path: "README.md".to_string(),
content: "Repo docs".to_string(),
source: DocumentSource::RepoDoc,
},
Document {
path: "scratch/plan.md".to_string(),
content: "Scratch plan".to_string(),
source: DocumentSource::Scratch,
},
],
summaries: vec![Document {
path: "wave-summary.md".to_string(),
content: "Summary".to_string(),
source: DocumentSource::Summary,
}],
wave_memory: Some(Document {
path: "wave/dev/MEMORY.md".to_string(),
content: "Memory".to_string(),
source: DocumentSource::WaveMemory,
}),
diff: Some("diff --git a/src/a.rs b/src/a.rs".to_string()),
diff_files: vec![Document {
path: "src/a.rs".to_string(),
content: "fn main() {}".to_string(),
source: DocumentSource::Diff,
}],
diff_file_count: 3,
area_docs: vec![Document {
path: "src/README.md".to_string(),
content: "Area".to_string(),
source: DocumentSource::Area,
}],
..Default::default()
};
let trimmed = trim_context_with_breakdown(GatheredContext(components), usize::MAX);
let breakdown = trimmed.breakdown();
assert_eq!(breakdown.source_count(DocumentSource::RepoDoc), 1);
assert_eq!(breakdown.source_count(DocumentSource::Scratch), 1);
assert_eq!(breakdown.source_count(DocumentSource::Summary), 1);
assert_eq!(breakdown.source_count(DocumentSource::WaveMemory), 1);
assert_eq!(breakdown.source_count(DocumentSource::Area), 1);
assert_eq!(breakdown.source_count(DocumentSource::Diff), 3);
assert!(breakdown
.documents
.iter()
.any(|entry| entry.path == "README.md"));
assert!(breakdown
.documents
.iter()
.any(|entry| entry.path == "scratch/plan.md"));
assert!(breakdown
.documents
.iter()
.any(|entry| entry.path == "wave-summary.md"));
assert!(breakdown
.documents
.iter()
.any(|entry| entry.path == "wave/dev/MEMORY.md"));
assert!(breakdown
.documents
.iter()
.any(|entry| entry.path == "src/a.rs"));
assert!(breakdown
.documents
.iter()
.any(|entry| entry.path == "src/README.md"));
}
#[test]
fn trim_context_drops_summaries_first() {
let components = PromptComponents {
docs: vec![Document {
path: "doc.md".to_string(),
content: "Doc content".to_string(),
source: DocumentSource::RepoDoc,
}],
summaries: vec![Document {
path: "summary.md".to_string(),
content: "Summary content that is long enough to matter".to_string(),
source: DocumentSource::Summary,
}],
..Default::default()
};
let doc_tokens = count_tokens("Doc content");
let trimmed = trim_context_with_breakdown(GatheredContext(components), doc_tokens + 5);
assert!(trimmed.components().summaries.is_empty());
assert_eq!(trimmed.components().docs.len(), 1);
}
#[test]
fn trim_context_drops_wave_memory_before_summaries() {
let components = PromptComponents {
step: Some(Step {
name: "test".to_string(),
content: Some("x".to_string()),
agent: None,
default_agent: None,
directions: vec![],
interactive: None,
action_style: None,
fast_path: None,
}),
wave_memory: Some(Document {
path: "wave/living/MEMORY.md".to_string(),
content: "Wave memory content that should be trimmed first".to_string(),
source: DocumentSource::WaveMemory,
}),
summaries: vec![Document {
path: "summary.md".to_string(),
content: "Summary should survive after wave memory is dropped".to_string(),
source: DocumentSource::Summary,
}],
..Default::default()
};
let budget = count_tokens("x")
+ count_tokens("Summary should survive after wave memory is dropped")
+ 1;
let trimmed = trim_context_with_breakdown(GatheredContext(components), budget);
assert!(trimmed.components().wave_memory.is_none());
assert_eq!(trimmed.components().summaries.len(), 1);
}
#[test]
fn trim_context_drops_docs_after_summaries() {
let components = PromptComponents {
docs: vec![
Document {
path: "doc1.md".to_string(),
content: "First document with enough content to exceed token budget easily"
.to_string(),
source: DocumentSource::RepoDoc,
},
Document {
path: "doc2.md".to_string(),
content: "Second document also has substantial content for testing".to_string(),
source: DocumentSource::RepoDoc,
},
],
summaries: vec![],
step: Some(Step {
name: "test".to_string(),
content: Some("x".to_string()), agent: None,
default_agent: None,
directions: vec![],
action_style: None,
interactive: None,
fast_path: None,
}),
..Default::default()
};
let step_tokens = count_tokens("x");
let trimmed = trim_context_with_breakdown(GatheredContext(components), step_tokens + 1);
assert!(trimmed.components().docs.is_empty());
assert!(trimmed.components().step.is_some());
}
#[test]
fn trim_context_drops_repo_docs_before_scratch_docs() {
let scratch_content =
"Scratch design notes with enough detail to keep around for implementation decisions";
let repo_doc_content = "Repo docs that should be dropped before scratch docs";
let components = PromptComponents {
docs: vec![
Document {
path: "scratch/plan.md".to_string(),
content: scratch_content.to_string(),
source: DocumentSource::Scratch,
},
Document {
path: "README.md".to_string(),
content: repo_doc_content.to_string(),
source: DocumentSource::RepoDoc,
},
],
step: Some(Step {
name: "test".to_string(),
content: Some("x".to_string()),
agent: None,
default_agent: None,
directions: vec![],
action_style: None,
interactive: None,
fast_path: None,
}),
..Default::default()
};
let budget = count_tokens("x") + count_tokens(scratch_content) + 1;
let trimmed = trim_context_with_breakdown(GatheredContext(components), budget);
assert_eq!(trimmed.components().docs.len(), 1);
assert_eq!(trimmed.components().docs[0].source, DocumentSource::Scratch);
}
#[test]
fn trim_context_drops_diff_after_docs() {
let components = PromptComponents {
docs: vec![],
diff: Some("This is a large diff with many changes across multiple files that will definitely exceed our small token budget".to_string()),
step: Some(Step {
name: "test".to_string(),
content: Some("x".to_string()), agent: None,
default_agent: None,
directions: vec![],
action_style: None,
interactive: None,
fast_path: None,
}),
..Default::default()
};
let step_tokens = count_tokens("x");
let trimmed = trim_context_with_breakdown(GatheredContext(components), step_tokens + 1);
assert!(trimmed.components().diff.is_none());
assert!(trimmed.components().step.is_some());
}
#[test]
fn trim_context_never_drops_step() {
let components = PromptComponents {
step: Some(Step {
name: "implement".to_string(),
content: Some("Implement the feature with tests".to_string()),
agent: None,
default_agent: None,
directions: vec![],
action_style: None,
interactive: None,
fast_path: None,
}),
docs: vec![Document {
path: "doc.md".to_string(),
content: "Doc content that will exceed budget".to_string(),
source: DocumentSource::RepoDoc,
}],
..Default::default()
};
let trimmed = trim_context_with_breakdown(GatheredContext(components), 5);
assert!(trimmed.components().step.is_some());
assert!(trimmed.components().docs.is_empty());
}
#[test]
fn format_prompt_basic() {
let components = PromptComponents {
surface: Surface::Headless,
..Default::default()
};
let prompt = render_full_prompt(components);
assert!(prompt.contains("Run mode is headless"));
assert!(prompt.contains("headless"));
assert!(prompt.contains("scratch/questions.md"));
assert!(prompt.contains("Output is logged, not displayed"));
}
#[test]
fn format_prompt_cli_surface_no_auto_message() {
let components = PromptComponents {
surface: Surface::Cli,
..Default::default()
};
let prompt = render_full_prompt(components);
assert!(!prompt.contains("Run mode is auto"));
assert!(prompt.contains("Run mode is interactive"));
}
#[test]
fn format_prompt_concerto_mac_surface_message() {
let components = PromptComponents {
surface: Surface::ConcertoMac,
..Default::default()
};
let prompt = render_full_prompt(components);
assert!(prompt.contains("Run mode is interactive"));
assert!(prompt.contains("Surface: Concerto (macOS)"));
}
#[test]
fn format_prompt_concerto_iphone_surface_message() {
let components = PromptComponents {
surface: Surface::ConcertoIphone,
..Default::default()
};
let prompt = render_full_prompt(components);
assert!(prompt.contains("Run mode is interactive"));
assert!(prompt.contains("Surface: Concerto (iPhone)"));
assert!(prompt.contains("Minimize back-and-forth"));
}
#[test]
fn format_prompt_with_wave() {
let components = PromptComponents {
wave: Some("rust".to_string()),
..Default::default()
};
let prompt = render_full_prompt(components);
assert!(prompt.contains("<lf:wave"));
assert!(prompt.contains("name=\"rust\""));
assert!(prompt.contains("rust program of work"));
assert!(prompt.contains("</lf:wave>"));
}
#[test]
fn format_prompt_with_wave_memory() {
let components = PromptComponents {
wave: Some("living".to_string()),
wave_memory: Some(Document {
path: "wave/living/MEMORY.md".to_string(),
content: "- prefer focused tests\n- run cargo fmt first".to_string(),
source: DocumentSource::WaveMemory,
}),
..Default::default()
};
let prompt = render_full_prompt(components);
assert!(prompt.contains("Persistent memory at wave/living/MEMORY.md"));
assert!(prompt.contains("<lf:memory path=\"wave/living/MEMORY.md\">"));
assert!(prompt.contains("prefer focused tests"));
}
#[test]
fn format_prompt_with_docs() {
let components = PromptComponents {
docs: vec![
Document {
path: "README.md".to_string(),
content: "# Test Project".to_string(),
source: DocumentSource::RepoDoc,
},
Document {
path: "STYLE.md".to_string(),
content: "# Style Guide".to_string(),
source: DocumentSource::RepoDoc,
},
],
..Default::default()
};
let prompt = render_full_prompt(components);
assert!(prompt.contains("<lf:docs>"));
assert!(prompt.contains("</lf:docs>"));
assert!(prompt.contains("<lf:README>"));
assert!(prompt.contains("# Test Project"));
assert!(prompt.contains("</lf:README>"));
assert!(prompt.contains("<lf:STYLE>"));
assert!(prompt.contains("# Style Guide"));
assert!(prompt.contains("Follow STYLE carefully"));
}
#[test]
fn format_prompt_claude_md_gets_follow_note() {
let components = PromptComponents {
docs: vec![Document {
path: "CLAUDE.md".to_string(),
content: "# Instructions".to_string(),
source: DocumentSource::RepoDoc,
}],
..Default::default()
};
let prompt = render_full_prompt(components);
assert!(prompt.contains("CLAUDE"));
assert!(prompt.contains("Follow") || prompt.contains("carefully"));
}
#[test]
fn format_prompt_style_md_gets_follow_note() {
let components = PromptComponents {
docs: vec![Document {
path: "STYLE.md".to_string(),
content: "# Style Guide".to_string(),
source: DocumentSource::RepoDoc,
}],
..Default::default()
};
let prompt = render_full_prompt(components);
assert!(prompt.contains("STYLE"));
assert!(prompt.contains("Follow") || prompt.contains("carefully"));
}
#[test]
fn format_prompt_with_single_direction() {
let components = PromptComponents {
directions: vec![Direction {
name: "concise".to_string(),
content: "Be concise and direct.".to_string(),
source: PathBuf::from(".lf/directions/concise.md"),
}],
..Default::default()
};
let prompt = render_full_prompt(components);
assert!(prompt.contains("<lf:direction:concise>"));
assert!(prompt.contains("Be concise and direct."));
assert!(prompt.contains("</lf:direction:concise>"));
assert!(prompt.contains("Direction for this work"));
assert!(!prompt.contains("<lf:directions>"));
}
#[test]
fn format_prompt_with_multiple_directions() {
let components = PromptComponents {
directions: vec![
Direction {
name: "concise".to_string(),
content: "Be concise.".to_string(),
source: PathBuf::from(".lf/directions/concise.md"),
},
Direction {
name: "architect".to_string(),
content: "Think architecturally.".to_string(),
source: PathBuf::from(".lf/directions/architect.md"),
},
],
..Default::default()
};
let prompt = render_full_prompt(components);
assert!(prompt.contains("<lf:directions>"));
assert!(prompt.contains("</lf:directions>"));
assert!(prompt.contains("<lf:direction:concise>"));
assert!(prompt.contains("<lf:direction:architect>"));
assert!(prompt.contains("Directions for this work"));
}
#[test]
fn format_prompt_with_step() {
let components = PromptComponents {
step: Some(Step {
name: "implement".to_string(),
content: Some("Implement the feature described.".to_string()),
agent: None,
default_agent: None,
directions: vec![],
action_style: None,
interactive: None,
fast_path: None,
}),
..Default::default()
};
let prompt = render_full_prompt(components);
assert!(prompt.contains("<lf:step:implement>"));
assert!(prompt.contains("Implement the feature described."));
assert!(prompt.contains("</lf:step:implement>"));
assert!(prompt.contains("The step."));
}
#[test]
fn format_prompt_with_step_no_content() {
let components = PromptComponents {
step: Some(Step {
name: "review".to_string(),
content: None,
agent: None,
default_agent: None,
directions: vec![],
action_style: None,
interactive: None,
fast_path: None,
}),
..Default::default()
};
let prompt = render_full_prompt(components);
assert!(prompt.contains("<lf:step:review>"));
assert!(prompt.contains("</lf:step:review>"));
}
#[test]
fn format_prompt_with_diff() {
let components = PromptComponents {
diff: Some("diff --git a/file.rs\n+added line".to_string()),
..Default::default()
};
let prompt = render_full_prompt(components);
assert!(prompt.contains("<lf:diff>"));
assert!(prompt.contains("+added line"));
assert!(prompt.contains("</lf:diff>"));
assert!(prompt.contains("Changes on this branch"));
}
#[test]
fn format_prompt_with_diff_files() {
let components = PromptComponents {
diff_files: vec![Document {
path: "src/main.rs".to_string(),
content: "fn main() { println!(\"hello\"); }".to_string(),
source: DocumentSource::Diff,
}],
..Default::default()
};
let prompt = render_full_prompt(components);
assert!(prompt.contains("<lf:files>"));
assert!(prompt.contains("<lf:file path=\"src/main.rs\">"));
assert!(prompt.contains("fn main()"));
assert!(prompt.contains("</lf:file>"));
assert!(prompt.contains("</lf:files>"));
}
#[test]
fn format_prompt_with_clipboard() {
let components = PromptComponents {
clipboard: Some("Error: connection refused".to_string()),
..Default::default()
};
let prompt = render_full_prompt(components);
assert!(prompt.contains("<lf:clipboard>"));
assert!(prompt.contains("Error: connection refused"));
assert!(prompt.contains("</lf:clipboard>"));
assert!(prompt.contains("Content from clipboard"));
}
#[test]
fn format_prompt_with_summaries() {
let components = PromptComponents {
summaries: vec![Document {
path: "src/".to_string(),
content: "Source code summary".to_string(),
source: DocumentSource::Summary,
}],
..Default::default()
};
let prompt = render_full_prompt(components);
assert!(prompt.contains("<lf:summaries>"));
assert!(prompt.contains("<lf:summary path=\"src/\">"));
assert!(prompt.contains("Source code summary"));
assert!(prompt.contains("</lf:summary>"));
assert!(prompt.contains("Pre-generated codebase summaries"));
}
#[test]
fn format_prompt_with_area_docs_uses_ancestor_descendant_label() {
let components = PromptComponents {
area: Some("src/api".to_string()),
area_docs: vec![Document {
path: "src/api/README.md".to_string(),
content: "# API".to_string(),
source: DocumentSource::Area,
}],
..Default::default()
};
let prompt = render_full_prompt(components);
assert!(prompt.contains("ancestor and descendant directories"));
}
#[test]
fn format_prompt_full_assembly() {
let components = PromptComponents {
surface: Surface::Headless,
wave: Some("rust".to_string()),
loopflow_doc: Some("Loopflow instructions".to_string()),
docs: vec![Document {
path: "README.md".to_string(),
content: "# Project".to_string(),
source: DocumentSource::RepoDoc,
}],
directions: vec![Direction {
name: "concise".to_string(),
content: "Be concise.".to_string(),
source: PathBuf::from(".lf/directions/concise.md"),
}],
step: Some(Step {
name: "implement".to_string(),
content: Some("Implement it.".to_string()),
agent: None,
default_agent: None,
directions: vec![],
action_style: None,
interactive: None,
fast_path: None,
}),
diff: Some("diff content".to_string()),
clipboard: Some("clipboard content".to_string()),
..Default::default()
};
let prompt = render_full_prompt(components);
let loopflow_pos = prompt.find("<lf:loopflow>").unwrap();
let auto_pos = prompt.find("Run mode is headless").unwrap();
let wave_pos = prompt.find("<lf:wave").unwrap();
let docs_pos = prompt.find("<lf:docs>").unwrap();
let diff_pos = prompt.find("<lf:diff>").unwrap();
let direction_pos = prompt.find("<lf:direction:concise>").unwrap();
let clipboard_pos = prompt.find("<lf:clipboard>").unwrap();
let step_pos = prompt.find("<lf:step:implement>").unwrap();
assert!(loopflow_pos < auto_pos);
assert!(auto_pos < wave_pos);
assert!(wave_pos < docs_pos);
assert!(docs_pos < diff_pos);
assert!(diff_pos < direction_pos);
assert!(direction_pos < clipboard_pos);
assert!(clipboard_pos < step_pos);
}
#[test]
fn format_prompt_default_components_has_headless_surface() {
let components = PromptComponents::default();
let prompt = render_full_prompt(components);
assert!(prompt.contains("Run mode is headless"));
}
#[test]
fn surface_parser_unknown_defaults_to_headless() {
let parsed = "unknown_surface"
.parse::<Surface>()
.expect("surface parsing is infallible");
assert_eq!(parsed, Surface::Headless);
}
#[test]
fn gather_area_docs_includes_ancestors_and_descendants() {
let repo = init_repo();
write_file(repo.path(), "src/README.md", "# src");
write_file(repo.path(), "src/api/README.md", "# api");
write_file(repo.path(), "src/api/handlers/README.md", "# handlers");
write_file(repo.path(), "src/api/handlers/v1/README.md", "# v1");
let docs = gather_area_docs(repo.path(), "src/api");
let paths: Vec<&str> = docs.iter().map(|doc| doc.path.as_str()).collect();
assert!(paths.contains(&"src/README.md"));
assert!(paths.contains(&"src/api/README.md"));
assert!(paths.contains(&"src/api/handlers/README.md"));
assert!(paths.contains(&"src/api/handlers/v1/README.md"));
let area_doc_count = docs
.iter()
.filter(|doc| doc.path == "src/api/README.md")
.count();
assert_eq!(area_doc_count, 1);
}
#[test]
fn gather_area_docs_excludes_sibling_directories() {
let repo = init_repo();
write_file(repo.path(), "src/README.md", "# src");
write_file(repo.path(), "src/api/README.md", "# api");
write_file(repo.path(), "src/api/handlers/README.md", "# handlers");
write_file(repo.path(), "src/web/README.md", "# web");
let docs = gather_area_docs(repo.path(), "src/api");
let paths: Vec<&str> = docs.iter().map(|doc| doc.path.as_str()).collect();
assert!(paths.contains(&"src/api/handlers/README.md"));
assert!(!paths.contains(&"src/web/README.md"));
}
#[test]
fn gather_area_docs_caps_descendants_at_100() {
let repo = init_repo();
write_file(repo.path(), "src/api/README.md", "# api");
for i in 0..120 {
write_file(
repo.path(),
&format!("src/api/handlers/doc-{i:03}.md"),
"# handler doc",
);
}
let docs = gather_area_docs(repo.path(), "src/api");
let descendant_count = docs
.iter()
.filter(|doc| doc.path.starts_with("src/api/handlers/"))
.count();
assert_eq!(descendant_count, 100);
}
#[test]
fn gather_files_excludes_gitignored() {
let repo = init_repo();
write_file(repo.path(), "src/main.rs", "fn main() {}");
write_file(repo.path(), "target/debug/main", "binary");
write_file(repo.path(), ".gitignore", "target/\n*.log");
write_file(repo.path(), "debug.log", "log content");
let files = gather_files(
repo.path(),
&[
"src/main.rs".to_string(),
"target/debug/main".to_string(),
"debug.log".to_string(),
],
)
.expect("gather files");
assert!(files.iter().any(|f| f.path.ends_with("src/main.rs")));
assert!(!files.iter().any(|f| f.path.contains("target")));
assert!(!files.iter().any(|f| f.path.ends_with("debug.log")));
}
#[test]
fn gather_files_excludes_lf_directory() {
let repo = init_repo();
write_file(repo.path(), "src/lib.rs", "pub fn foo() {}");
write_file(repo.path(), ".lf/config.yaml", "model: claude");
write_file(repo.path(), ".lf/steps/debug.md", "# Debug step");
let files = gather_all_text_files(repo.path()).expect("gather files");
assert!(files.iter().any(|f| f.path.ends_with("src/lib.rs")));
assert!(!files.iter().any(|f| f.path.contains(".lf/")));
}
#[test]
fn gather_context_with_specific_files() {
let repo = init_repo();
write_file(repo.path(), "src/a.rs", "mod a;");
write_file(repo.path(), "src/b.rs", "mod b;");
write_file(repo.path(), "src/c.rs", "mod c;");
let opts = GatherContextOpts {
repo_root: repo.path().to_path_buf(),
files: vec!["src/a.rs".to_string(), "src/c.rs".to_string()],
sources: vec![],
..Default::default()
};
let ctx = gather_context(&opts).expect("gather context");
let prompt = format_prompt(
PromptFormatMode::Full,
&trim_context_with_breakdown(ctx, usize::MAX),
)
.into_string();
assert!(prompt.contains("mod a;"));
assert!(prompt.contains("mod c;"));
assert!(!prompt.contains("mod b;"));
}
#[test]
fn gather_files_deduplicates_requests() {
let repo = init_repo();
write_file(repo.path(), "src/main.rs", "fn main() {}");
let files = gather_files(
repo.path(),
&[
"src/main.rs".to_string(),
"src/main.rs".to_string(),
"./src/main.rs".to_string(),
],
)
.expect("gather files");
let main_count = files
.iter()
.filter(|f| f.path.ends_with("src/main.rs"))
.count();
assert_eq!(main_count, 1);
}
#[test]
fn gather_context_with_specific_files_does_not_pull_branch_diff() {
let repo = init_git_repo();
Command::new("git")
.args(["checkout", "-b", "feature"])
.current_dir(repo.path())
.output()
.expect("git checkout");
write_file(repo.path(), "src/a.rs", "mod a;");
write_file(repo.path(), "src/unrelated.rs", "mod unrelated;");
Command::new("git")
.args(["add", "src/a.rs", "src/unrelated.rs"])
.current_dir(repo.path())
.output()
.expect("git add");
let opts = GatherContextOpts {
repo_root: repo.path().to_path_buf(),
files: vec!["src/a.rs".to_string()],
sources: vec![],
..Default::default()
};
let ctx = gather_context(&opts).expect("gather context");
let has_diff = ctx.diff.is_some();
let prompt = format_prompt(
PromptFormatMode::Full,
&trim_context_with_breakdown(ctx, usize::MAX),
)
.into_string();
assert!(
!has_diff,
"files-only context should not include branch diff"
);
assert!(!prompt.contains("<lf:diff>"));
assert!(prompt.contains("mod a;"));
assert!(!prompt.contains("mod unrelated;"));
}
#[test]
fn gather_files_skips_binary_files() {
let repo = init_repo();
write_file(repo.path(), "src/main.rs", "fn main() {}");
write_binary(repo.path(), "assets/image.png", &[0x89, 0x50, 0x4E, 0x47]);
let files = gather_files(
repo.path(),
&["src/main.rs".to_string(), "assets/image.png".to_string()],
)
.expect("gather files");
assert!(files.iter().any(|f| f.path.ends_with("src/main.rs")));
assert!(!files.iter().any(|f| f.path.ends_with("image.png")));
}
#[test]
fn gather_context_minimal_repo() {
let temp = tempfile::tempdir().expect("create temp dir");
let repo = temp.path();
std::fs::create_dir_all(repo.join(".lf/steps")).expect("create .lf/steps");
std::fs::write(repo.join(".lf/steps/test.md"), "Test step content").expect("write step");
let opts = GatherContextOpts {
repo_root: repo.to_path_buf(),
step: Some("test".to_string()),
sources: vec![],
..Default::default()
};
let result = gather_context(&opts);
assert!(result.is_ok());
let components = result.unwrap();
assert!(components.step.is_some());
assert_eq!(components.step.as_ref().unwrap().name, "test");
}
#[test]
fn gather_context_with_lfdocs() {
let temp = tempfile::tempdir().expect("create temp dir");
let repo = temp.path();
std::fs::write(repo.join("README.md"), "# Project").expect("write readme");
std::fs::create_dir_all(repo.join("scratch")).expect("create scratch");
std::fs::write(repo.join("scratch/plan.md"), "# Plan").expect("write plan");
let opts = GatherContextOpts {
repo_root: repo.to_path_buf(),
sources: vec![DocumentSource::RepoDoc],
..Default::default()
};
let result = gather_context(&opts);
assert!(result.is_ok());
let components = result.unwrap();
assert!(!components.docs.is_empty());
let readme = components.docs.iter().find(|d| d.path.contains("README"));
assert!(readme.is_some());
assert_eq!(
readme.expect("README should be gathered").source,
DocumentSource::RepoDoc
);
let scratch = components
.docs
.iter()
.find(|d| d.path.contains("scratch/plan.md"));
assert!(scratch.is_some());
assert_eq!(
scratch.expect("scratch doc should be gathered").source,
DocumentSource::Scratch
);
}
#[test]
fn gather_context_with_directions() {
let temp = tempfile::tempdir().expect("create temp dir");
let repo = temp.path();
std::fs::create_dir_all(repo.join(".lf/directions")).expect("create directions");
std::fs::write(repo.join(".lf/directions/concise.md"), "Be concise.")
.expect("write direction");
let opts = GatherContextOpts {
repo_root: repo.to_path_buf(),
directions: vec!["concise".to_string()],
sources: vec![],
..Default::default()
};
let result = gather_context(&opts);
assert!(result.is_ok());
let components = result.unwrap();
assert_eq!(components.directions.len(), 1);
assert_eq!(components.directions[0].name, "concise");
assert!(components.directions[0].content.contains("Be concise"));
}
#[test]
fn directions_from_step_and_cli_combined() {
let repo = init_repo();
write_file(
repo.path(),
".lf/steps/impl.md",
r#"---
directions:
- thorough
---
# Implement
"#,
);
write_file(repo.path(), ".lf/directions/thorough.md", "Be thorough.");
write_file(repo.path(), ".lf/directions/fast.md", "Be fast.");
let opts = GatherContextOpts {
repo_root: repo.path().to_path_buf(),
step: Some("impl".to_string()),
directions: vec!["fast".to_string()],
sources: vec![],
..Default::default()
};
let ctx = gather_context(&opts).expect("gather context");
assert_eq!(ctx.directions.len(), 2);
assert_eq!(ctx.directions[0].name, "thorough");
assert_eq!(ctx.directions[1].name, "fast");
}
#[test]
fn gather_context_surface_preserved() {
let temp = tempfile::tempdir().expect("create temp dir");
let repo = temp.path();
let opts = GatherContextOpts {
repo_root: repo.to_path_buf(),
surface: Surface::Cli,
sources: vec![],
..Default::default()
};
let result = gather_context(&opts);
assert!(result.is_ok());
let components = result.unwrap();
assert_eq!(components.surface, Surface::Cli);
}
#[test]
fn gather_context_wave_preserved() {
let temp = tempfile::tempdir().expect("create temp dir");
let repo = temp.path();
let opts = GatherContextOpts {
repo_root: repo.to_path_buf(),
wave: Some("rust-migration".to_string()),
sources: vec![],
..Default::default()
};
let result = gather_context(&opts);
assert!(result.is_ok());
let components = result.unwrap();
assert_eq!(components.wave, Some("rust-migration".to_string()));
}
#[test]
fn gather_context_reads_wave_memory() {
let repo = init_repo();
write_file(repo.path(), "wave/living/README.md", "# Living");
write_file(
repo.path(),
"wave/living/MEMORY.md",
"- always run rustfmt before commit",
);
let opts = GatherContextOpts {
repo_root: repo.path().to_path_buf(),
wave: Some("living".to_string()),
sources: vec![DocumentSource::RepoDoc],
..Default::default()
};
let result = gather_context(&opts);
assert!(result.is_ok());
let components = result.unwrap();
assert!(components.wave_memory.is_some());
assert_eq!(
components.wave_memory.as_ref().map(|d| d.source),
Some(DocumentSource::WaveMemory)
);
assert!(components
.wave_memory
.as_ref()
.expect("wave memory should be loaded")
.content
.contains("always run rustfmt before commit"));
}
#[test]
fn write_prompt_log_creates_file() {
let repo = init_repo();
let prompt = "Test prompt content";
let path = write_prompt_log(repo.path(), prompt, "implement", None).unwrap();
assert!(path.exists());
assert!(path.to_string_lossy().contains(".lf/prompts/"));
assert!(path.to_string_lossy().ends_with("-implement.md"));
let content = fs::read_to_string(&path).unwrap();
assert_eq!(content, prompt);
}
#[test]
fn write_prompt_log_adds_to_repo_gitignore() {
let repo = init_repo();
let gitignore_path = repo.path().join(".gitignore");
assert!(!gitignore_path.exists());
write_prompt_log(repo.path(), "test", "step", None).unwrap();
assert!(gitignore_path.exists());
let content = fs::read_to_string(&gitignore_path).unwrap();
assert!(content.contains(".lf/prompts/"));
}
#[test]
fn write_prompt_log_with_flow_parents() {
let repo = init_repo();
let path = write_prompt_log(
repo.path(),
"content",
"implement",
Some(&["ship".to_string(), "grind".to_string()]),
)
.unwrap();
assert!(path.to_string_lossy().contains("ship.grind.implement.md"));
}
#[test]
fn write_prompt_log_preserves_existing_gitignore() {
let repo = init_repo();
let gitignore_path = repo.path().join(".gitignore");
fs::write(&gitignore_path, "target/\n.lf/prompts/\n").unwrap();
write_prompt_log(repo.path(), "test", "step", None).unwrap();
let content = fs::read_to_string(&gitignore_path).unwrap();
assert_eq!(content, "target/\n.lf/prompts/\n");
}
#[test]
fn write_prompt_log_appends_to_existing_gitignore() {
let repo = init_repo();
let gitignore_path = repo.path().join(".gitignore");
fs::write(&gitignore_path, "target/\nnode_modules/\n").unwrap();
write_prompt_log(repo.path(), "test", "step", None).unwrap();
let content = fs::read_to_string(&gitignore_path).unwrap();
assert!(content.contains("target/"));
assert!(content.contains("node_modules/"));
assert!(content.contains(".lf/prompts/"));
}
#[test]
fn format_context_prompt_excludes_step() {
let components = PromptComponents {
surface: Surface::Headless,
step: Some(Step {
name: "implement".to_string(),
content: Some("Implement the feature.".to_string()),
agent: None,
default_agent: None,
directions: vec![],
action_style: None,
interactive: None,
fast_path: None,
}),
..Default::default()
};
let context = format_context_prompt(&budget_context(components));
assert!(!context.contains("<lf:step:implement>"));
assert!(!context.contains("Implement the feature."));
assert!(context.contains("Run mode is headless"));
}
#[test]
fn format_context_prompt_includes_all_context() {
let components = PromptComponents {
surface: Surface::Cli,
docs: vec![Document {
path: "README.md".to_string(),
content: "# Project".to_string(),
source: DocumentSource::RepoDoc,
}],
directions: vec![Direction {
name: "concise".to_string(),
content: "Be concise.".to_string(),
source: PathBuf::from(".lf/directions/concise.md"),
}],
clipboard: Some("Error message".to_string()),
step: Some(Step {
name: "debug".to_string(),
content: Some("Fix the error.".to_string()),
agent: None,
default_agent: None,
directions: vec![],
action_style: None,
interactive: None,
fast_path: None,
}),
..Default::default()
};
let context = format_context_prompt(&budget_context(components));
assert!(context.contains("Run mode is interactive"));
assert!(context.contains("<lf:docs>"));
assert!(context.contains("# Project"));
assert!(context.contains("<lf:clipboard>"));
assert!(context.contains("<lf:direction:concise>"));
assert!(context.contains("Be concise."));
assert!(!context.contains("<lf:step:debug>"));
assert!(!context.contains("Fix the error."));
}
#[test]
fn format_context_prompt_cli_surface_message() {
let components = PromptComponents {
surface: Surface::Cli,
..Default::default()
};
let context = format_context_prompt(&budget_context(components));
assert!(context.contains("Run mode is interactive"));
assert!(context.contains("ask questions"));
assert!(context.contains("wait for feedback"));
}
#[test]
fn format_task_prompt_returns_step_content() {
let components = PromptComponents {
step: Some(Step {
name: "implement".to_string(),
content: Some("Implement the feature.".to_string()),
agent: None,
default_agent: None,
directions: vec![],
action_style: None,
interactive: None,
fast_path: None,
}),
..Default::default()
};
let task = format_task_prompt(&budget_context(components));
assert!(task.contains("<lf:step:implement>"));
assert!(task.contains("Implement the feature."));
assert!(task.contains("</lf:step:implement>"));
}
#[test]
fn format_task_prompt_empty_when_no_step_or_message() {
let components = PromptComponents::default();
let task = format_task_prompt(&budget_context(components));
assert!(task.is_empty());
}
#[test]
fn format_task_prompt_includes_message() {
let components = PromptComponents {
message: Some("fix the login bug".to_string()),
..Default::default()
};
let task = format_task_prompt(&budget_context(components));
assert_eq!(task, "fix the login bug");
}
#[test]
fn format_task_prompt_message_with_step() {
let components = PromptComponents {
step: Some(Step {
name: "debug".to_string(),
content: Some("Debug the error.".to_string()),
agent: None,
default_agent: None,
directions: vec![],
action_style: None,
interactive: None,
fast_path: None,
}),
message: Some("login page crashes".to_string()),
..Default::default()
};
let task = format_task_prompt(&budget_context(components));
assert!(task.contains("<lf:step:debug>"));
assert!(task.contains("login page crashes"));
}
#[test]
fn format_task_prompt_step_without_content() {
let components = PromptComponents {
step: Some(Step {
name: "review".to_string(),
content: None,
agent: None,
default_agent: None,
directions: vec![],
action_style: None,
interactive: None,
fast_path: None,
}),
..Default::default()
};
let task = format_task_prompt(&budget_context(components));
assert!(task.contains("<lf:step:review>"));
assert!(task.contains("</lf:step:review>"));
}
fn init_git_repo() -> tempfile::TempDir {
let dir = tempfile::tempdir().expect("tempdir");
Command::new("git")
.args(["init", "-b", "main"])
.current_dir(dir.path())
.output()
.expect("git init");
Command::new("git")
.args(["config", "user.email", "test@test.com"])
.current_dir(dir.path())
.output()
.expect("git config email");
Command::new("git")
.args(["config", "user.name", "Test"])
.current_dir(dir.path())
.output()
.expect("git config name");
std::fs::write(dir.path().join("README.md"), "# Test").expect("write readme");
Command::new("git")
.args(["add", "."])
.current_dir(dir.path())
.output()
.expect("git add");
Command::new("git")
.args(["commit", "-m", "init"])
.current_dir(dir.path())
.output()
.expect("git commit");
dir
}
#[test]
fn gather_diff_tiered_on_main_returns_none() {
let repo = init_git_repo();
let (diff, tier, _) = gather_diff_tiered(repo.path()).unwrap();
assert!(diff.is_none());
assert_eq!(tier, DiffTier::None);
}
#[test]
fn gather_diff_tiered_uncommitted_changes_on_branch() {
let repo = init_git_repo();
Command::new("git")
.args(["checkout", "-b", "feature"])
.current_dir(repo.path())
.output()
.expect("git checkout");
std::fs::write(repo.path().join("new_file.rs"), "fn hello() {}").expect("write file");
Command::new("git")
.args(["add", "new_file.rs"])
.current_dir(repo.path())
.output()
.expect("git add");
let (diff, tier, count) = gather_diff_tiered(repo.path()).unwrap();
assert!(diff.is_some(), "should find uncommitted changes");
assert_eq!(tier, DiffTier::UnifiedDiff);
assert_eq!(count, 1);
assert!(diff.unwrap().contains("fn hello()"));
}
#[test]
fn gather_diff_tiered_committed_changes_on_branch() {
let repo = init_git_repo();
let origin_dir = tempfile::tempdir().expect("origin tempdir");
let origin_path = origin_dir.path().join("origin.git");
Command::new("git")
.args([
"clone",
"--bare",
repo.path().to_str().unwrap(),
origin_path.to_str().unwrap(),
])
.output()
.expect("git clone bare");
Command::new("git")
.args(["remote", "add", "origin", origin_path.to_str().unwrap()])
.current_dir(repo.path())
.output()
.expect("git remote add");
Command::new("git")
.args(["fetch", "origin"])
.current_dir(repo.path())
.output()
.expect("git fetch");
Command::new("git")
.args(["checkout", "-b", "feature"])
.current_dir(repo.path())
.output()
.expect("git checkout");
std::fs::write(repo.path().join("committed.rs"), "fn committed() {}").expect("write file");
Command::new("git")
.args(["add", "committed.rs"])
.current_dir(repo.path())
.output()
.expect("git add");
Command::new("git")
.args(["commit", "-m", "add committed file"])
.current_dir(repo.path())
.output()
.expect("git commit");
let (diff, tier, count) = gather_diff_tiered(repo.path()).unwrap();
assert!(diff.is_some(), "should find committed changes");
assert_eq!(tier, DiffTier::UnifiedDiff);
assert_eq!(count, 1);
assert!(diff.unwrap().contains("fn committed()"));
}
#[test]
fn gather_documents_cross_repo_area_includes_root_docs() {
let session_repo = init_repo();
write_file(session_repo.path(), "CLAUDE.md", "session claude");
let related_repo = tempfile::tempdir().expect("related tempdir");
std::fs::write(related_repo.path().join("CLAUDE.md"), "related claude").unwrap();
std::fs::write(related_repo.path().join("STYLE.md"), "related style").unwrap();
std::fs::create_dir_all(related_repo.path().join("src")).unwrap();
std::fs::write(related_repo.path().join("src/README.md"), "src area doc").unwrap();
let related = RelatedRepoContext {
repo_id: RepoId::parse("acme/widgets").unwrap(),
path: related_repo.path().to_path_buf(),
};
let spec = GatherSpec {
sources: vec![DocumentSource::RepoDoc, DocumentSource::Area],
repo_root: session_repo.path().to_path_buf(),
area: Some("widgets:src".to_string()),
related_repos: vec![related],
..Default::default()
};
let docs = gather_documents(&spec).unwrap();
assert!(docs
.iter()
.any(|d| d.path == "CLAUDE.md" && d.content == "session claude"));
assert!(docs
.iter()
.any(|d| d.path == "[acme/widgets] CLAUDE.md" && d.content == "related claude"));
assert!(docs
.iter()
.any(|d| d.path == "[acme/widgets] STYLE.md" && d.content == "related style"));
assert!(docs
.iter()
.any(|d| d.path.contains("[acme/widgets]") && d.content == "src area doc"));
}
#[test]
fn gather_documents_related_repo_docs_not_loaded_without_area() {
let session_repo = init_repo();
write_file(session_repo.path(), "CLAUDE.md", "session claude");
let related_repo = tempfile::tempdir().expect("related tempdir");
std::fs::write(related_repo.path().join("CLAUDE.md"), "related claude").unwrap();
let related = RelatedRepoContext {
repo_id: RepoId::parse("acme/widgets").unwrap(),
path: related_repo.path().to_path_buf(),
};
let spec = GatherSpec {
sources: vec![DocumentSource::RepoDoc],
repo_root: session_repo.path().to_path_buf(),
related_repos: vec![related],
..Default::default()
};
let docs = gather_documents(&spec).unwrap();
assert!(docs
.iter()
.any(|d| d.path == "CLAUDE.md" && d.content == "session claude"));
assert!(!docs.iter().any(|d| d.path.contains("[acme/widgets]")));
}
#[test]
fn gather_documents_no_related_repos_unchanged() {
let repo = init_repo();
write_file(repo.path(), "README.md", "hello");
let spec = GatherSpec {
sources: vec![DocumentSource::RepoDoc],
repo_root: repo.path().to_path_buf(),
..Default::default()
};
let docs = gather_documents(&spec).unwrap();
let repo_docs: Vec<_> = docs
.iter()
.filter(|d| d.source == DocumentSource::RepoDoc)
.collect();
assert!(repo_docs.iter().any(|d| d.path == "README.md"));
assert!(!repo_docs.iter().any(|d| d.path.starts_with('[')));
}
#[test]
fn gather_documents_related_repo_missing_from_disk() {
let repo = init_repo();
write_file(repo.path(), "README.md", "hello");
let related = RelatedRepoContext {
repo_id: RepoId::parse("acme/gone").unwrap(),
path: PathBuf::from("/nonexistent/path/that/does/not/exist"),
};
let spec = GatherSpec {
sources: vec![DocumentSource::Area],
repo_root: repo.path().to_path_buf(),
area: Some("gone:src".to_string()),
related_repos: vec![related],
..Default::default()
};
let docs = gather_documents(&spec).unwrap();
assert!(!docs.iter().any(|d| d.path.contains("[acme/gone]")));
}
#[test]
fn gather_documents_cross_repo_area() {
let session_repo = init_repo();
let related_repo = tempfile::tempdir().expect("related tempdir");
std::fs::write(related_repo.path().join("CLAUDE.md"), "studio claude").unwrap();
std::fs::create_dir_all(related_repo.path().join("swift")).unwrap();
std::fs::write(
related_repo.path().join("swift/README.md"),
"swift area doc",
)
.unwrap();
let related = RelatedRepoContext {
repo_id: RepoId::parse("acme/studio").unwrap(),
path: related_repo.path().to_path_buf(),
};
let spec = GatherSpec {
sources: vec![DocumentSource::Area],
repo_root: session_repo.path().to_path_buf(),
area: Some("studio:swift".to_string()),
related_repos: vec![related],
..Default::default()
};
let docs = gather_documents(&spec).unwrap();
assert!(
docs.iter()
.any(|d| d.path == "[acme/studio] CLAUDE.md" && d.content == "studio claude"),
"expected related repo root doc, got: {:?}",
docs.iter().map(|d| &d.path).collect::<Vec<_>>()
);
assert!(
docs.iter()
.any(|d| d.path.contains("[acme/studio]") && d.content == "swift area doc"),
"expected cross-repo area doc, got: {:?}",
docs.iter().map(|d| &d.path).collect::<Vec<_>>()
);
}
#[test]
fn gather_documents_bare_repo_name_loads_whole_repo() {
let session_repo = init_repo();
let related_repo = tempfile::tempdir().expect("related tempdir");
std::fs::write(related_repo.path().join("CLAUDE.md"), "studio claude").unwrap();
std::fs::write(related_repo.path().join("README.md"), "studio readme").unwrap();
let related = RelatedRepoContext {
repo_id: RepoId::parse("acme/studio").unwrap(),
path: related_repo.path().to_path_buf(),
};
let spec = GatherSpec {
sources: vec![DocumentSource::Area],
repo_root: session_repo.path().to_path_buf(),
area: Some("studio:".to_string()),
related_repos: vec![related],
..Default::default()
};
let docs = gather_documents(&spec).unwrap();
assert!(docs
.iter()
.any(|d| d.path == "[acme/studio] CLAUDE.md" && d.content == "studio claude"));
assert!(docs
.iter()
.any(|d| d.path.contains("[acme/studio]") && d.content == "studio readme"));
}
#[test]
fn gather_documents_local_area_unchanged() {
let repo = init_repo();
std::fs::create_dir_all(repo.path().join("docs")).unwrap();
write_file(repo.path(), "docs/README.md", "local area doc");
let spec = GatherSpec {
sources: vec![DocumentSource::Area],
repo_root: repo.path().to_path_buf(),
area: Some("docs".to_string()),
..Default::default()
};
let docs = gather_documents(&spec).unwrap();
assert!(docs.iter().any(|d| d.content == "local area doc"));
assert!(!docs.iter().any(|d| d.path.starts_with('[')));
}
#[test]
fn resolve_area_local_without_colon() {
let result = resolve_area("docs", &[]);
assert!(matches!(result, ResolvedArea::Local { area: "docs" }));
}
#[test]
fn resolve_area_cross_repo_match() {
let related = vec![RelatedRepoContext {
repo_id: RepoId::parse("acme/studio").unwrap(),
path: PathBuf::from("/repos/studio"),
}];
let result = resolve_area("studio:swift", &related);
match result {
ResolvedArea::CrossRepo { related: r, area } => {
assert_eq!(r.repo_id.name(), "studio");
assert_eq!(area, "swift");
}
_ => panic!("expected CrossRepo"),
}
}
#[test]
fn resolve_area_unknown_repo_falls_back_to_local() {
let related = vec![RelatedRepoContext {
repo_id: RepoId::parse("acme/studio").unwrap(),
path: PathBuf::from("/repos/studio"),
}];
let result = resolve_area("unknown:swift", &related);
assert!(matches!(
result,
ResolvedArea::Local {
area: "unknown:swift"
}
));
}
#[test]
fn resolve_area_ambiguous_repo_falls_back_to_local() {
let related = vec![
RelatedRepoContext {
repo_id: RepoId::parse("acme/studio").unwrap(),
path: PathBuf::from("/repos/studio1"),
},
RelatedRepoContext {
repo_id: RepoId::parse("other/studio").unwrap(),
path: PathBuf::from("/repos/studio2"),
},
];
let result = resolve_area("studio:swift", &related);
assert!(matches!(result, ResolvedArea::Local { .. }));
}
#[test]
fn resolve_area_empty_repo_name_treated_as_local() {
let result = resolve_area(":swift", &[]);
assert!(matches!(result, ResolvedArea::Local { area: ":swift" }));
}
#[test]
fn resolve_area_bare_repo_name_resolves_to_root() {
let related = vec![RelatedRepoContext {
repo_id: RepoId::parse("acme/studio").unwrap(),
path: PathBuf::from("/repos/studio"),
}];
let result = resolve_area("studio:", &related);
match result {
ResolvedArea::CrossRepo { related: r, area } => {
assert_eq!(r.repo_id.name(), "studio");
assert_eq!(area, ".");
}
_ => panic!("expected CrossRepo, got Local"),
}
}
}