devsql 0.2.1

Unified SQL queries across Claude Code + Git data
Documentation
//! `devsql impact` -- analyze a file's exported symbols and potential dependents.

use incurs::command::{CommandContext, CommandDef, CommandHandler, Example};
use incurs::output::CommandResult;
use serde_json::{json, Value};

use super::engine_from_options;

// ---------------------------------------------------------------------------
// Schema
// ---------------------------------------------------------------------------

#[derive(incurs::Args, serde::Deserialize)]
#[allow(dead_code)]
struct ImpactArgs {
    /// File path (or partial path) to analyze
    file: String,
}

#[derive(incurs::Options, serde::Deserialize)]
#[allow(dead_code)]
struct ImpactOptions {
    /// Git repository path
    #[incurs(alias = "r", default = ".")]
    repo: String,
    /// Claude data directory (defaults to ~/.claude)
    #[incurs(alias = "d")]
    data_dir: Option<String>,
    /// Depth of dependency analysis (reserved for future use)
    #[incurs(default = "1")]
    depth: i64,
}

// ---------------------------------------------------------------------------
// Handler
// ---------------------------------------------------------------------------

struct ImpactHandler;

#[async_trait::async_trait]
impl CommandHandler for ImpactHandler {
    async fn run(&self, ctx: CommandContext) -> CommandResult {
        let file = match ctx.args.get("file").and_then(|v| v.as_str()) {
            Some(f) => f.to_string(),
            None => {
                return CommandResult::Error {
                    code: "MISSING_ARG".into(),
                    message: "Missing required argument: file".into(),
                    retryable: false,
                    exit_code: Some(1),
                    cta: None,
                };
            }
        };

        let (mut engine, _repo_path) = match engine_from_options(&ctx.options) {
            Ok(v) => v,
            Err(e) => return e,
        };

        // Load symbols and imports tables
        if let Err(e) = engine.load_code_tables(&["symbols", "imports"]) {
            return CommandResult::Error {
                code: "LOAD_ERROR".into(),
                message: format!("Failed to load code tables: {e}"),
                retryable: false,
                exit_code: Some(1),
                cta: None,
            };
        }

        // Find exported symbols from the target file
        let exports_sql = format!(
            "SELECT name, kind, line_start, signature, visibility \
             FROM symbols \
             WHERE file_path LIKE '%{file}%' \
               AND (visibility = 'pub' OR visibility = 'public' OR visibility IS NULL) \
             ORDER BY line_start"
        );

        let exported_symbols = match engine.query(&exports_sql) {
            Ok(rows) => rows,
            Err(e) => {
                return CommandResult::Error {
                    code: "QUERY_ERROR".into(),
                    message: format!("Exports query failed: {e}"),
                    retryable: false,
                    exit_code: Some(1),
                    cta: None,
                };
            }
        };

        // Collect exported symbol names for dependency search
        let symbol_names: Vec<String> = exported_symbols
            .iter()
            .filter_map(|s| s.get("name").and_then(|v| v.as_str()).map(String::from))
            .collect();

        // Find potential dependents via imports table
        let mut potential_dependents = Vec::new();
        if !symbol_names.is_empty() {
            // Build a query to find files that import from this module
            let imports_sql = format!(
                "SELECT DISTINCT file_path, source \
                 FROM imports \
                 WHERE source LIKE '%{file}%'"
            );

            if let Ok(import_rows) = engine.query(&imports_sql) {
                for row in import_rows {
                    potential_dependents.push(row);
                }
            }

            // Also search for files that reference exported symbol names
            // via the symbols table (e.g., types used as parameters/return types)
            for name in &symbol_names {
                let refs_sql = format!(
                    "SELECT DISTINCT file_path, name, kind \
                     FROM symbols \
                     WHERE file_path NOT LIKE '%{file}%' \
                       AND (parameters LIKE '%{name}%' OR return_type LIKE '%{name}%') \
                     LIMIT 20"
                );

                if let Ok(ref_rows) = engine.query(&refs_sql) {
                    for row in ref_rows {
                        potential_dependents.push(row);
                    }
                }
            }
        }

        CommandResult::Ok {
            data: json!({
                "file_pattern": file,
                "exported_symbols": Value::Array(exported_symbols),
                "potential_dependents": Value::Array(potential_dependents),
            }),
            cta: None,
        }
    }
}

// ---------------------------------------------------------------------------
// Builder
// ---------------------------------------------------------------------------

pub fn build() -> CommandDef {
    CommandDef::build("impact", ImpactHandler)
        .description("Analyze a file's exported symbols and potential dependents")
        .args::<ImpactArgs>()
        .options::<ImpactOptions>()
        .examples(vec![
            Example {
                command: "src/engine.rs --json".to_string(),
                description: Some("Analyze impact of changes to engine.rs".to_string()),
            },
        ])
        .done()
}