a3s 0.9.5

a3s — A3S coding agent CLI; `a3s code` launches the interactive TUI
Documentation
use std::path::{Path, PathBuf};
use std::sync::Arc;

use a3s_boot::{BootError, Result as BootResult};
use a3s_code_core::{
    CodeIntelligenceError, CodePosition, NavigationKind, WorkspaceCodeIntelligence, WorkspacePath,
    WorkspaceServices,
};
use tokio_util::sync::CancellationToken;

use super::dto::{
    CodeIntelligenceStatusResponse, DiagnosticResponse, DocumentSymbolResponse, NavigationResponse,
    WorkspaceSymbolResponse,
};
use crate::api::code_web::state::CodeWebState;

const DEFAULT_SYMBOL_LIMIT: usize = 100;
const MAX_SYMBOL_LIMIT: usize = 500;

pub(in crate::api::code_web) struct CodeIntelligenceService {
    state: Arc<CodeWebState>,
}

impl CodeIntelligenceService {
    pub(in crate::api::code_web) fn new(state: Arc<CodeWebState>) -> Self {
        Self { state }
    }

    pub(in crate::api::code_web) async fn status(
        &self,
        session_id: Option<String>,
    ) -> BootResult<CodeIntelligenceStatusResponse> {
        let provider = self.provider(session_id.as_deref()).await?;
        Ok(provider.status().into())
    }

    pub(in crate::api::code_web) async fn document_outline(
        &self,
        path: String,
        session_id: Option<String>,
    ) -> BootResult<DocumentSymbolResponse> {
        let services = self.services(session_id.as_deref()).await?;
        let path = served_document_path(&services, &path).await?;
        let provider = provider_from(&services)?;
        provider
            .document_symbols(&path, CancellationToken::new())
            .await
            .map(Into::into)
            .map_err(map_query_error)
    }

    pub(in crate::api::code_web) async fn search_symbols(
        &self,
        query: String,
        limit: Option<usize>,
        session_id: Option<String>,
    ) -> BootResult<WorkspaceSymbolResponse> {
        let query = query.trim();
        if query.is_empty() {
            return Err(BootError::BadRequest("query is required".to_owned()));
        }
        let limit = parse_symbol_limit(limit)?;
        self.provider(session_id.as_deref())
            .await?
            .search_symbols(query, limit, CancellationToken::new())
            .await
            .map(Into::into)
            .map_err(map_query_error)
    }

    pub(in crate::api::code_web) async fn navigate(
        &self,
        path: String,
        line: u32,
        character: u32,
        kind: String,
        session_id: Option<String>,
    ) -> BootResult<NavigationResponse> {
        let services = self.services(session_id.as_deref()).await?;
        let path = served_document_path(&services, &path).await?;
        let kind = parse_navigation_kind(&kind)?;
        let provider = provider_from(&services)?;
        provider
            .navigate(
                kind,
                &path,
                CodePosition::new(line, character),
                CancellationToken::new(),
            )
            .await
            .map(Into::into)
            .map_err(map_query_error)
    }

    pub(in crate::api::code_web) async fn diagnostics(
        &self,
        path: Option<String>,
        session_id: Option<String>,
    ) -> BootResult<DiagnosticResponse> {
        let services = self.services(session_id.as_deref()).await?;
        let path = match path {
            Some(path) => Some(served_document_path(&services, &path).await?),
            None => None,
        };
        let provider = provider_from(&services)?;
        provider
            .diagnostics(path.as_ref(), CancellationToken::new())
            .await
            .map(Into::into)
            .map_err(map_query_error)
    }

    async fn services(&self, session_id: Option<&str>) -> BootResult<Arc<WorkspaceServices>> {
        let workspace = {
            let sessions = self.state.sessions.lock().await;
            resolve_served_workspace(&self.state.default_workspace, session_id, |session_id| {
                sessions
                    .get(session_id)
                    .map(|session| session.workspace().to_path_buf())
            })?
        };
        self.state
            .workspace_services_for(&workspace)
            .await
            .map_err(|error| BootError::ServiceUnavailable(error.to_string()))
    }

    async fn provider(
        &self,
        session_id: Option<&str>,
    ) -> BootResult<Arc<dyn WorkspaceCodeIntelligence>> {
        let services = self.services(session_id).await?;
        provider_from(&services)
    }
}

fn resolve_served_workspace(
    default_workspace: &Path,
    session_id: Option<&str>,
    session_workspace: impl FnOnce(&str) -> Option<PathBuf>,
) -> BootResult<PathBuf> {
    let Some(session_id) = session_id else {
        return Ok(default_workspace.to_path_buf());
    };
    let session_id = session_id.trim();
    if session_id.is_empty() {
        return Err(BootError::BadRequest(
            "sessionId must not be empty".to_owned(),
        ));
    }
    session_workspace(session_id)
        .ok_or_else(|| BootError::NotFound(format!("session `{session_id}` was not found")))
}

fn provider_from(services: &WorkspaceServices) -> BootResult<Arc<dyn WorkspaceCodeIntelligence>> {
    services.code_intelligence().ok_or_else(|| {
        BootError::ServiceUnavailable("Code Intelligence is not available".to_owned())
    })
}

async fn served_document_path(
    services: &WorkspaceServices,
    input: &str,
) -> BootResult<WorkspacePath> {
    let input = input.trim();
    if input.is_empty() {
        return Err(BootError::BadRequest("path is required".to_owned()));
    }
    if is_absolute_path(input) {
        return Err(BootError::BadRequest(
            "path must be relative to the served workspace".to_owned(),
        ));
    }
    let path = services
        .normalize_path(input)
        .map_err(|error| BootError::BadRequest(error.to_string()))?;
    if path.is_root() {
        return Err(BootError::BadRequest(
            "path must identify a saved document".to_owned(),
        ));
    }

    let root = services
        .local_root()
        .ok_or_else(|| BootError::Internal("served workspace has no local root".to_owned()))?;
    let resolved = tokio::fs::canonicalize(root.join(path.as_str()))
        .await
        .map_err(|error| {
            BootError::BadRequest(format!(
                "saved document `{}` cannot be resolved: {error}",
                path.as_str()
            ))
        })?;
    if !resolved.starts_with(root) {
        return Err(BootError::Forbidden(
            "path resolves outside the served workspace".to_owned(),
        ));
    }
    let metadata = tokio::fs::metadata(&resolved)
        .await
        .map_err(|error| BootError::BadRequest(error.to_string()))?;
    if !metadata.is_file() {
        return Err(BootError::BadRequest(
            "path must identify a saved document".to_owned(),
        ));
    }
    Ok(path)
}

fn is_absolute_path(input: &str) -> bool {
    let bytes = input.as_bytes();
    Path::new(input).is_absolute()
        || (bytes.len() >= 2 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':')
        || input.starts_with("\\\\")
        || input.starts_with("//")
}

fn parse_navigation_kind(input: &str) -> BootResult<NavigationKind> {
    match input.trim() {
        "definition" => Ok(NavigationKind::Definition),
        "declaration" => Ok(NavigationKind::Declaration),
        "references" => Ok(NavigationKind::References),
        "implementations" => Ok(NavigationKind::Implementations),
        _ => Err(BootError::BadRequest(
            "kind must be definition, declaration, references, or implementations".to_owned(),
        )),
    }
}

fn parse_symbol_limit(limit: Option<usize>) -> BootResult<usize> {
    match limit {
        None => Ok(DEFAULT_SYMBOL_LIMIT),
        Some(limit @ 1..=MAX_SYMBOL_LIMIT) => Ok(limit),
        Some(_) => Err(BootError::BadRequest(format!(
            "limit must be between 1 and {MAX_SYMBOL_LIMIT}"
        ))),
    }
}

fn map_query_error(error: CodeIntelligenceError) -> BootError {
    let message = format!("{}: {error}", error.code());
    match error {
        CodeIntelligenceError::InvalidPath { .. }
        | CodeIntelligenceError::InvalidPosition { .. } => BootError::BadRequest(message),
        CodeIntelligenceError::Timeout { .. } | CodeIntelligenceError::Cancelled => {
            BootError::GatewayTimeout(message)
        }
        CodeIntelligenceError::Unsupported { .. } => BootError::NotImplemented(message),
        CodeIntelligenceError::ProcessExited { .. } | CodeIntelligenceError::Unavailable { .. } => {
            BootError::ServiceUnavailable(message)
        }
        CodeIntelligenceError::Protocol { .. } => BootError::BadGateway(message),
        _ => BootError::Internal(message),
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn absolute_path_forms_are_detected() {
        assert!(is_absolute_path("/tmp/main.rs"));
        assert!(is_absolute_path("C:\\temp\\main.rs"));
        assert!(is_absolute_path("\\\\server\\share\\main.rs"));
    }

    #[tokio::test]
    async fn document_paths_are_relative_and_confined_to_served_workspace() {
        let workspace = tempfile::tempdir().expect("temporary workspace");
        let source_dir = workspace.path().join("src");
        tokio::fs::create_dir(&source_dir)
            .await
            .expect("create source directory");
        let source = source_dir.join("main.rs");
        tokio::fs::write(&source, "fn main() {}")
            .await
            .expect("write source");
        let services = WorkspaceServices::local(workspace.path());

        let path = served_document_path(&services, "src/main.rs")
            .await
            .expect("relative source path");
        assert_eq!(path.as_str(), "src/main.rs");
        assert!(
            served_document_path(&services, &source.display().to_string())
                .await
                .is_err()
        );
        assert!(served_document_path(&services, "../../outside.rs")
            .await
            .is_err());
    }

    #[cfg(unix)]
    #[tokio::test]
    async fn document_symlink_cannot_escape_served_workspace() {
        let workspace = tempfile::tempdir().expect("temporary workspace");
        let outside = tempfile::tempdir().expect("outside directory");
        let outside_source = outside.path().join("outside.rs");
        tokio::fs::write(&outside_source, "fn outside() {}")
            .await
            .expect("write outside source");
        std::os::unix::fs::symlink(&outside_source, workspace.path().join("linked.rs"))
            .expect("create symlink");
        let services = WorkspaceServices::local(workspace.path());

        let error = served_document_path(&services, "linked.rs")
            .await
            .expect_err("symlink escape must be rejected");

        assert!(matches!(error, BootError::Forbidden(_)));
    }

    #[test]
    fn navigation_kind_contract_is_explicit() {
        assert_eq!(
            parse_navigation_kind("definition").expect("definition"),
            NavigationKind::Definition
        );
        assert!(parse_navigation_kind("typeDefinition").is_err());
    }

    #[test]
    fn symbol_limit_is_bounded_without_silent_clamping() {
        assert_eq!(parse_symbol_limit(None).expect("default limit"), 100);
        assert_eq!(parse_symbol_limit(Some(500)).expect("maximum limit"), 500);
        assert!(parse_symbol_limit(Some(0)).is_err());
        assert!(parse_symbol_limit(Some(501)).is_err());
    }

    #[test]
    fn semantic_errors_keep_stable_codes_and_http_categories() {
        let error = map_query_error(CodeIntelligenceError::InvalidPosition {
            path: WorkspacePath::from_normalized("src/main.rs"),
            position: CodePosition::new(4, 8),
        });

        assert!(matches!(error, BootError::BadRequest(_)));
        assert!(error
            .to_string()
            .contains("CODE_INTELLIGENCE_INVALID_POSITION"));
    }

    #[test]
    fn unknown_session_cannot_select_an_arbitrary_workspace() {
        let error = resolve_served_workspace(Path::new("/served"), Some("missing"), |_| None)
            .expect_err("unknown session must be rejected");

        assert!(matches!(error, BootError::NotFound(_)));
    }
}