robotrt-cli 0.1.0-beta.1

RobotRT modular robotics runtime and middleware components.
use super::*;

pub(in crate::commands::sdk) fn resolve_migrate_batch_project_dirs(
    args: &[String],
) -> Result<Vec<PathBuf>, String> {
    let mut project_values = Vec::new();
    if let Some(raw) = option_value(args, "--projects") {
        project_values.extend(split_csv_values(&raw));
    }

    if let Some(raw_file_path) = option_value(args, "--projects-file") {
        project_values.extend(read_batch_project_values(Path::new(&raw_file_path))?);
    }

    if let Some(raw_scan_root) = option_value(args, "--scan-root") {
        let discovered = discover_projects_from_root(Path::new(&raw_scan_root))?;
        project_values.extend(
            discovered
                .into_iter()
                .map(|path| path.display().to_string())
                .collect::<Vec<_>>(),
        );
    }

    if project_values.is_empty() {
        return Err(String::from(
            "missing project list (usage: sdk migrate-batch --projects <dir1,dir2,...> [--projects-file <path>] [--scan-root <dir>])",
        ));
    }

    let mut unique = HashSet::new();
    let mut result = Vec::new();
    for value in project_values {
        let trimmed = value.trim();
        if trimmed.is_empty() {
            continue;
        }
        if unique.insert(trimmed.to_string()) {
            result.push(PathBuf::from(trimmed));
        }
    }

    if result.is_empty() {
        return Err(String::from(
            "no valid project directories found in --projects/--projects-file",
        ));
    }

    Ok(result)
}

pub(in crate::commands::sdk) fn discover_projects_from_root(
    root: &Path,
) -> Result<Vec<PathBuf>, String> {
    if !root.exists() {
        return Err(format!("scan root not found: {}", root.display()));
    }
    if !root.is_dir() {
        return Err(format!("scan root is not a directory: {}", root.display()));
    }

    let mut projects = Vec::new();
    walk_project_dirs(root, &mut projects)?;
    Ok(projects)
}

pub(in crate::commands::sdk) fn walk_project_dirs(
    dir: &Path,
    projects: &mut Vec<PathBuf>,
) -> Result<(), String> {
    let entries = fs::read_dir(dir)
        .map_err(|err| format!("read directory {} failed: {err}", dir.display()))?
        .collect::<Result<Vec<_>, _>>()
        .map_err(|err| format!("read directory {} failed: {err}", dir.display()))?;

    let has_robotrt_config = entries.iter().any(|entry| {
        entry
            .file_name()
            .to_str()
            .is_some_and(|name| name == "robotrt.toml")
    });

    if has_robotrt_config {
        projects.push(dir.to_path_buf());
        return Ok(());
    }

    for entry in entries {
        let path = entry.path();
        if path.is_dir() {
            walk_project_dirs(&path, projects)?;
        }
    }

    Ok(())
}

pub(in crate::commands::sdk) fn parse_optional_u64_option(
    args: &[String],
    option: &str,
) -> Result<Option<u64>, String> {
    let Some(raw) = option_value(args, option) else {
        return Ok(None);
    };

    raw.parse::<u64>()
        .map(Some)
        .map_err(|err| format!("invalid value for {option}: {raw} ({err})"))
}

pub(in crate::commands::sdk) fn inspect_project_profile(
    project_dir: &Path,
    target_schema_version: u64,
) -> Result<SdkProjectProfile, String> {
    let config_path = project_dir.join("robotrt.toml");
    let backup_path = project_dir.join("robotrt.toml.bak");

    if !config_path.exists() {
        return Ok(SdkProjectProfile {
            project_dir: project_dir.to_path_buf(),
            schema_version: None,
            template: None,
            missing_config: true,
            backup_conflict: backup_path.exists(),
            downgrade_risk: false,
        });
    }

    let content = fs::read_to_string(&config_path)
        .map_err(|err| format!("read {} failed: {err}", config_path.display()))?;
    let schema_version = parse_schema_version(&content);
    let template = parse_project_template(&content);
    let downgrade_risk = schema_version
        .map(|value| value > target_schema_version)
        .unwrap_or(false);

    Ok(SdkProjectProfile {
        project_dir: project_dir.to_path_buf(),
        schema_version,
        template,
        missing_config: false,
        backup_conflict: backup_path.exists(),
        downgrade_risk,
    })
}

pub(in crate::commands::sdk) fn split_csv_values(raw: &str) -> Vec<String> {
    raw.split(',')
        .map(str::trim)
        .filter(|value| !value.is_empty())
        .map(ToString::to_string)
        .collect()
}

pub(in crate::commands::sdk) fn read_batch_project_values(
    path: &Path,
) -> Result<Vec<String>, String> {
    let content = fs::read_to_string(path)
        .map_err(|err| format!("read project list file {} failed: {err}", path.display()))?;
    Ok(content
        .lines()
        .map(str::trim)
        .filter(|line| !line.is_empty() && !line.starts_with('#'))
        .map(ToString::to_string)
        .collect())
}

pub(in crate::commands::sdk) fn read_json_file(path: &Path) -> Result<serde_json::Value, String> {
    let content =
        fs::read_to_string(path).map_err(|err| format!("read {} failed: {err}", path.display()))?;
    serde_json::from_str::<serde_json::Value>(&content)
        .map_err(|err| format!("parse {} failed: {err}", path.display()))
}

pub(in crate::commands::sdk) fn parse_batch_report_projects(
    path: &Path,
) -> Result<Vec<BatchReportProject>, String> {
    let payload = read_json_file(path)?;
    let projects = payload
        .get("result")
        .and_then(|value| value.get("projects"))
        .and_then(serde_json::Value::as_array)
        .ok_or_else(|| {
            format!(
                "batch report {} missing result.projects array",
                path.display()
            )
        })?;

    let mut results = Vec::new();
    for item in projects {
        let project_dir = item
            .get("project_dir")
            .and_then(serde_json::Value::as_str)
            .ok_or_else(|| format!("batch report {} has invalid project_dir", path.display()))?;
        let status = item
            .get("status")
            .and_then(serde_json::Value::as_str)
            .unwrap_or("unknown")
            .to_string();
        let backup_path = item
            .get("backup_path")
            .and_then(serde_json::Value::as_str)
            .map(PathBuf::from);
        let error = item
            .get("error")
            .and_then(serde_json::Value::as_str)
            .map(ToString::to_string);

        results.push(BatchReportProject {
            project_dir: PathBuf::from(project_dir),
            status,
            backup_path,
            error,
        });
    }

    Ok(results)
}

pub(in crate::commands::sdk) fn parse_batch_report_target_schema(
    path: &Path,
) -> Result<Option<u64>, String> {
    let payload = read_json_file(path)?;
    Ok(payload
        .get("query")
        .and_then(|value| value.get("target_schema_version"))
        .and_then(serde_json::Value::as_u64))
}

pub(in crate::commands::sdk) fn write_json_payload_file(
    path: &Path,
    payload: &serde_json::Value,
    label: &str,
) -> Result<(), String> {
    if let Some(parent) = path.parent() {
        fs::create_dir_all(parent)
            .map_err(|err| format!("create output dir {} failed: {err}", parent.display()))?;
    }
    let body = serde_json::to_string_pretty(payload)
        .map_err(|err| format!("serialize {label} failed: {err}"))?;
    fs::write(path, body)
        .map_err(|err| format!("write {} to {} failed: {err}", label, path.display()))
}

pub(in crate::commands::sdk) fn resolve_migrate_project_dir(
    args: &[String],
) -> Result<PathBuf, String> {
    let project_dir = option_value(args, "--project")
        .or_else(|| first_positional(args))
        .ok_or_else(|| String::from("missing project dir (usage: sdk migrate <project-dir>)"))?;
    let path = PathBuf::from(project_dir);
    if !path.exists() {
        return Err(format!("project dir not found: {}", path.display()));
    }
    if !path.is_dir() {
        return Err(format!(
            "project path is not a directory: {}",
            path.display()
        ));
    }
    Ok(path)
}