use std::path::PathBuf;
use std::sync::Arc;
use serde::{Deserialize, Serialize};
use shigoto_types::{JobScope, JobSubject, OutputSink, RecordingJob};
use tokio::sync::Mutex as AsyncMutex;
use zoekt_mcp::client::{ListRequest, ZoektClient};
use crate::state::{self, RunState};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ContextOutcome {
pub changed: bool,
pub content_hash: String,
pub summary_path: PathBuf,
pub typescape_present: bool,
pub zoekt_reachable: bool,
}
#[derive(Debug, thiserror::Error)]
pub enum ContextError {
#[error("failed to write {path:?}: {source}")]
Write {
path: PathBuf,
#[source]
source: std::io::Error,
},
#[error("failed to persist ponte state: {0}")]
State(#[from] anyhow::Error),
}
pub struct ContextAssembleJob {
pub repo_path: PathBuf,
pub repo_name: String,
pub zoekt: ZoektClient,
pub state_dir: PathBuf,
pub outcome_cell: Arc<AsyncMutex<Option<ContextOutcome>>>,
pub output_sink: Option<Arc<dyn OutputSink<ContextOutcome>>>,
}
impl ContextAssembleJob {
async fn read_typescape(&self) -> Option<serde_yaml::Value> {
let path = self.repo_path.join(".typescape.yaml");
let bytes = tokio::fs::read(&path).await.ok()?;
serde_yaml::from_slice(&bytes).ok()
}
async fn zoekt_stats(&self) -> (bool, String) {
let req = ListRequest {
q: format!("repo:{}", self.repo_name),
};
match self.zoekt.list_repos(&req).await {
Ok(resp) => {
let repos = resp.list.repos.unwrap_or_default();
if repos.is_empty() {
(true, "not indexed by zoekt".to_string())
} else {
let r = &repos[0];
(
true,
format!(
"{} files, {:.1} MB indexed",
r.stats.documents,
r.stats.content_bytes as f64 / 1_048_576.0
),
)
}
}
Err(e) => (false, format!("zoekt unreachable: {e}")),
}
}
fn render_summary(&self, typescape: &Option<serde_yaml::Value>, zoekt_note: &str) -> String {
let mut out = String::new();
out.push_str("<!-- Auto-regenerated by ponte on every run. Do not hand-edit. -->\n");
out.push_str(&format!("# {} — typescape summary\n\n", self.repo_name));
match typescape {
Some(v) => {
out.push_str("## Typescape\n\n```yaml\n");
out.push_str(&serde_yaml::to_string(v).unwrap_or_default());
out.push_str("```\n\n");
}
None => {
out.push_str("## Typescape\n\nNo `.typescape.yaml` in this repo.\n\n");
}
}
out.push_str("## zoekt\n\n");
out.push_str(zoekt_note);
out.push('\n');
out
}
}
#[async_trait::async_trait]
impl RecordingJob for ContextAssembleJob {
type Output = ContextOutcome;
type Error = ContextError;
const KIND: &'static str = "ponte.context-assemble";
fn scope(&self) -> JobScope {
JobScope::Repo {
workspace: "ponte".into(),
repo: self.repo_name.clone(),
}
}
fn subject(&self) -> JobSubject {
JobSubject::Path(self.repo_path.clone())
}
fn output_sink(&self) -> Option<&Arc<dyn OutputSink<Self::Output>>> {
self.output_sink.as_ref()
}
async fn execute_body(&self) -> Result<ContextOutcome, ContextError> {
let prior = state::load(&self.state_dir, &self.repo_path);
let typescape = self.read_typescape().await;
let typescape_present = typescape.is_some();
let (zoekt_reachable, zoekt_note) = self.zoekt_stats().await;
let summary = self.render_summary(&typescape, &zoekt_note);
let content_hash = blake3::hash(summary.as_bytes()).to_hex().to_string();
let summary_path = self.repo_path.join("docs").join("typescape-summary.md");
if let Some(parent) = summary_path.parent() {
tokio::fs::create_dir_all(parent)
.await
.map_err(|source| ContextError::Write {
path: summary_path.clone(),
source,
})?;
}
tokio::fs::write(&summary_path, &summary)
.await
.map_err(|source| ContextError::Write {
path: summary_path.clone(),
source,
})?;
let changed = prior.last_content_hash.as_deref() != Some(content_hash.as_str());
let new_state = RunState {
last_content_hash: Some(content_hash.clone()),
last_run_at: Some(chrono::Utc::now()),
};
state::save(&self.state_dir, &self.repo_path, &new_state)?;
let outcome = ContextOutcome {
changed,
content_hash,
summary_path,
typescape_present,
zoekt_reachable,
};
*self.outcome_cell.lock().await = Some(outcome.clone());
Ok(outcome)
}
}
#[cfg(test)]
fn unreachable_zoekt_client() -> ZoektClient {
ZoektClient::with_timeout("http://127.0.0.1:1", std::time::Duration::from_millis(200))
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::Path;
fn job(dir: &Path, cell: Arc<AsyncMutex<Option<ContextOutcome>>>) -> ContextAssembleJob {
ContextAssembleJob {
repo_path: dir.to_path_buf(),
repo_name: "test-repo".into(),
zoekt: unreachable_zoekt_client(),
state_dir: dir.join(".ponte-state"),
outcome_cell: cell,
output_sink: None,
}
}
#[tokio::test]
async fn writes_summary_and_marks_changed_on_first_run() {
let dir = tempfile::tempdir().unwrap();
let outcome = job(dir.path(), Arc::new(AsyncMutex::new(None)))
.execute_body()
.await
.unwrap();
assert!(outcome.changed);
assert!(!outcome.typescape_present);
assert!(!outcome.zoekt_reachable);
assert!(outcome.summary_path.exists());
let contents = std::fs::read_to_string(&outcome.summary_path).unwrap();
assert!(contents.contains("test-repo"));
assert!(contents.contains("No `.typescape.yaml`"));
}
#[tokio::test]
async fn second_run_with_no_change_is_unchanged() {
let dir = tempfile::tempdir().unwrap();
let j = job(dir.path(), Arc::new(AsyncMutex::new(None)));
let first = j.execute_body().await.unwrap();
assert!(first.changed);
let second = j.execute_body().await.unwrap();
assert!(!second.changed, "identical rerun must not report changed");
assert_eq!(first.content_hash, second.content_hash);
}
#[tokio::test]
async fn reads_typescape_yaml_when_present() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join(".typescape.yaml"), "vocabulary: [foo, bar]\n").unwrap();
let outcome = job(dir.path(), Arc::new(AsyncMutex::new(None)))
.execute_body()
.await
.unwrap();
assert!(outcome.typescape_present);
let contents = std::fs::read_to_string(&outcome.summary_path).unwrap();
assert!(contents.contains("foo"));
}
#[tokio::test]
async fn changing_typescape_content_flips_changed_on_third_run() {
let dir = tempfile::tempdir().unwrap();
let state_dir = dir.path().join(".ponte-state");
let mk = || ContextAssembleJob {
repo_path: dir.path().to_path_buf(),
repo_name: "test-repo".into(),
zoekt: unreachable_zoekt_client(),
state_dir: state_dir.clone(),
outcome_cell: Arc::new(AsyncMutex::new(None)),
output_sink: None,
};
let first = mk().execute_body().await.unwrap();
assert!(first.changed);
let second = mk().execute_body().await.unwrap();
assert!(!second.changed);
std::fs::write(dir.path().join(".typescape.yaml"), "vocabulary: [changed]\n").unwrap();
let third = mk().execute_body().await.unwrap();
assert!(third.changed, "editing .typescape.yaml must flip changed");
assert_ne!(first.content_hash, third.content_hash);
}
#[tokio::test]
async fn outcome_cell_is_populated_after_execute() {
let dir = tempfile::tempdir().unwrap();
let cell = Arc::new(AsyncMutex::new(None));
job(dir.path(), cell.clone()).execute_body().await.unwrap();
assert!(cell.lock().await.is_some());
}
}