a3s 0.8.0

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

use a3s_boot::{BootError, Result as BootResult};
use serde_json::{json, Value};

use super::controller::{PluginReloadRequest, PluginToggleRequest};
use crate::api::code_web::session_runtime::rebuild_code_web_sessions;
use crate::api::code_web::state::CodeWebState;
use crate::tui::skills::{
    agent_skill_dirs, count_skill_files, load_disabled_skills, load_skills, save_disabled_skills,
};

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

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

    pub(in crate::api::code_web) async fn list(
        &self,
        workspace: Option<String>,
    ) -> BootResult<Value> {
        Ok(self.snapshot(workspace, Vec::new(), false))
    }

    pub(in crate::api::code_web) async fn set_enabled(
        &self,
        raw_name: &str,
        request: PluginToggleRequest,
    ) -> BootResult<Value> {
        let name = normalize_skill_name(raw_name)?;
        let dirs = self.default_skill_dirs();
        let skills = load_skills(&dirs);
        if !skills.iter().any(|(skill_name, _)| skill_name == &name) {
            return Err(BootError::NotFound(format!(
                "skill/plugin `{name}` was not found"
            )));
        }

        let mut disabled = load_disabled_skills();
        let enabled = apply_enabled(&mut disabled, &name, request.enabled);
        save_disabled_skills(&disabled);

        let mut response = self.snapshot(None, Vec::new(), false);
        if let Some(object) = response.as_object_mut() {
            object.insert(
                "updated".to_string(),
                json!({
                    "name": name,
                    "enabled": enabled,
                }),
            );
        }
        Ok(response)
    }

    pub(in crate::api::code_web) async fn reload(
        &self,
        request: PluginReloadRequest,
    ) -> BootResult<Value> {
        let rebuild_sessions = request.rebuild_sessions.unwrap_or(true);
        let rebuilt_sessions = if rebuild_sessions {
            self.rebuild_sessions().await?
        } else {
            Vec::new()
        };

        Ok(self.snapshot(None, rebuilt_sessions, true))
    }

    fn snapshot(
        &self,
        workspace: Option<String>,
        rebuilt_sessions: Vec<Value>,
        reloaded: bool,
    ) -> Value {
        let workspace = self.workspace_from_request(workspace);
        let workspace_text = workspace.display().to_string();
        let dirs = agent_skill_dirs(&workspace_text);
        let disabled = load_disabled_skills();
        let mut sources_by_name: BTreeMap<String, Vec<Value>> = BTreeMap::new();

        let dir_summaries = dirs
            .iter()
            .map(|dir| {
                let dir_skills = load_skills(std::slice::from_ref(dir));
                for (name, _) in &dir_skills {
                    sources_by_name
                        .entry(name.clone())
                        .or_default()
                        .push(json!({
                            "path": dir.display().to_string(),
                        }));
                }
                json!({
                    "path": dir.display().to_string(),
                    "exists": dir.is_dir(),
                    "itemCount": count_skill_files(std::slice::from_ref(dir)),
                })
            })
            .collect::<Vec<_>>();

        let skills = load_skills(&dirs);
        let total = skills.len();
        let items = skills
            .into_iter()
            .map(|(name, description)| {
                let enabled = !disabled.contains(&name);
                json!({
                    "name": name,
                    "command": format!("/{name}"),
                    "description": description,
                    "enabled": enabled,
                    "sources": sources_by_name.remove(&name).unwrap_or_default(),
                })
            })
            .collect::<Vec<_>>();
        let disabled_count = items
            .iter()
            .filter(|item| item.get("enabled").and_then(Value::as_bool) == Some(false))
            .count();

        json!({
            "workspaceRoot": workspace.display().to_string(),
            "dirs": dir_summaries,
            "items": items,
            "total": total,
            "enabledCount": total.saturating_sub(disabled_count),
            "disabledCount": disabled_count,
            "reloaded": reloaded,
            "reloadedAt": if reloaded { Some(chrono::Utc::now().to_rfc3339()) } else { None },
            "rebuiltSessions": rebuilt_sessions,
        })
    }

    async fn rebuild_sessions(&self) -> BootResult<Vec<Value>> {
        rebuild_code_web_sessions(self.state.as_ref()).await
    }

    fn workspace_from_request(&self, workspace: Option<String>) -> PathBuf {
        workspace
            .as_deref()
            .map(str::trim)
            .filter(|value| !value.is_empty())
            .map(PathBuf::from)
            .unwrap_or_else(|| self.state.default_workspace.clone())
    }

    fn default_skill_dirs(&self) -> Vec<PathBuf> {
        agent_skill_dirs(&self.state.default_workspace.display().to_string())
    }
}

fn normalize_skill_name(raw_name: &str) -> BootResult<String> {
    let name = raw_name.trim().trim_start_matches('/').trim();
    if name.is_empty() {
        return Err(BootError::BadRequest(
            "skill/plugin name is required".to_string(),
        ));
    }
    Ok(name.to_string())
}

fn apply_enabled(disabled: &mut HashSet<String>, name: &str, enabled: Option<bool>) -> bool {
    let target_enabled = enabled.unwrap_or_else(|| disabled.contains(name));
    if target_enabled {
        disabled.remove(name);
    } else {
        disabled.insert(name.to_string());
    }
    target_enabled
}

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

    #[test]
    fn apply_enabled_matches_plugin_toggle_semantics() {
        let mut disabled = HashSet::from(["reviewer".to_string()]);
        assert!(apply_enabled(&mut disabled, "reviewer", None));
        assert!(!disabled.contains("reviewer"));

        assert!(!apply_enabled(&mut disabled, "reviewer", None));
        assert!(disabled.contains("reviewer"));

        assert!(apply_enabled(&mut disabled, "reviewer", Some(true)));
        assert!(!disabled.contains("reviewer"));

        assert!(!apply_enabled(&mut disabled, "reviewer", Some(false)));
        assert!(disabled.contains("reviewer"));
    }
}