procyon 0.1.0

Terminal development harness for Stellar and Soroban smart contracts, driven by a language model
pub mod accounts;
pub mod bindings;
pub mod caatinga;
pub mod docs;
pub mod events;
pub mod file;
pub mod invoke;
pub mod mainnet;
pub mod party;
pub mod paths;
pub mod persona;
pub mod plugin;
pub mod project;
pub mod search;
pub mod skill;
pub mod test;
pub mod update;

use async_trait::async_trait;
use serde_json::Value;

// A Stellar contract id is exactly 56 chars of uppercase base32 starting with 'C'.
pub fn is_contract_id(candidate: &str) -> bool {
    candidate.len() == 56
        && candidate.starts_with('C')
        && candidate
            .bytes()
            .all(|b| b.is_ascii_uppercase() || b.is_ascii_digit())
}

#[async_trait]
pub trait Tool: Send + Sync {
    fn name(&self) -> &str;
    fn description(&self) -> &str;
    fn input_schema(&self) -> Value;
    async fn execute(&self, input: Value) -> Result<String, String>;
}

pub struct ToolRegistry {
    tools: Vec<Box<dyn Tool>>,
}

impl ToolRegistry {
    pub fn new() -> Self {
        Self { tools: Vec::new() }
    }

    pub fn register(&mut self, tool: Box<dyn Tool>) {
        self.tools.push(tool);
    }

    // Plugin tools are named by a user-authored manifest, so a name may collide with a builtin.
    // Refusing keeps a plugin from shadowing something like `write_file`.
    pub fn try_register(&mut self, tool: Box<dyn Tool>) -> Result<(), String> {
        if self.get_tool(tool.name()).is_some() {
            return Err(format!(
                "tool '{}' is already registered and was skipped",
                tool.name()
            ));
        }
        self.tools.push(tool);
        Ok(())
    }

    pub fn get_tool(&self, name: &str) -> Option<&dyn Tool> {
        self.tools
            .iter()
            .find(|t| t.name() == name)
            .map(|t| t.as_ref())
    }

    pub fn definitions(&self) -> Vec<crate::agent::ToolDefinition> {
        self.tools
            .iter()
            .map(|t| crate::agent::ToolDefinition {
                name: t.name().to_string(),
                description: t.description().to_string(),
                input_schema: t.input_schema(),
            })
            .collect()
    }

    pub async fn execute(&self, name: &str, input: Value) -> Result<String, String> {
        let tool = self
            .get_tool(name)
            .ok_or_else(|| format!("Unknown tool: {}", name))?;
        tool.execute(input).await
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use serde_json::json;
    use std::sync::atomic::{AtomicUsize, Ordering};
    use std::sync::Arc;

    // Stands in for a slow external CLI: it awaits, so a correctly-written tool yields the
    // runtime while it waits.
    struct SlowTool;

    #[async_trait]
    impl Tool for SlowTool {
        fn name(&self) -> &str {
            "slow"
        }
        fn description(&self) -> &str {
            "sleeps"
        }
        fn input_schema(&self) -> Value {
            json!({"type": "object"})
        }
        async fn execute(&self, _input: Value) -> Result<String, String> {
            tokio::time::sleep(std::time::Duration::from_millis(300)).await;
            Ok("done".to_string())
        }
    }

    #[tokio::test(flavor = "current_thread")]
    async fn a_slow_tool_does_not_stall_the_render_loop() {
        let mut registry = ToolRegistry::new();
        registry.register(Box::new(SlowTool));

        let frames = Arc::new(AtomicUsize::new(0));
        let ticker = {
            let frames = frames.clone();
            tokio::spawn(async move {
                loop {
                    tokio::time::sleep(std::time::Duration::from_millis(10)).await;
                    frames.fetch_add(1, Ordering::Relaxed);
                }
            })
        };

        let result = registry.execute("slow", json!({})).await.unwrap();
        ticker.abort();

        assert_eq!(result, "done");
        // A single-threaded runtime is used on purpose: a blocking tool would starve the ticker
        // completely, exactly as it starved the TUI redraw.
        assert!(
            frames.load(Ordering::Relaxed) > 5,
            "render loop only advanced {} frames during a 300ms tool",
            frames.load(Ordering::Relaxed)
        );
    }

    #[tokio::test(flavor = "current_thread")]
    async fn npx_check_does_not_spawn_a_process() {
        // A PATH lookup must stay fast enough to sit on the hot path of every build/deploy.
        let start = std::time::Instant::now();
        for _ in 0..50 {
            let _ = crate::tools::caatinga::check_npx_available();
        }
        assert!(
            start.elapsed() < std::time::Duration::from_millis(250),
            "50 npx checks took {:?}; that is process-spawn territory",
            start.elapsed()
        );
    }
}