ito-core 0.1.33

Core functionality and business logic for Ito
Documentation
//! Embedded asset distribution helpers.
//!
//! This module builds install manifests for the various harnesses Ito supports.
//! The manifests map a file embedded in `ito-templates` to a destination path on
//! disk.

use crate::errors::{CoreError, CoreResult};
use ito_templates::{
    commands_files, get_adapter_file, get_command_file, get_skill_file, skills_files,
};
use std::path::{Path, PathBuf};

#[derive(Debug, Clone)]
/// One file to be installed from embedded assets.
pub struct FileManifest {
    /// Source path relative to embedded assets (e.g., "ito-proposal/SKILL.md" for skills)
    pub source: String,
    /// Destination path on disk
    pub dest: PathBuf,
    /// Asset type determines which embedded directory to read from
    pub asset_type: AssetType,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
/// Category of embedded asset.
pub enum AssetType {
    /// A skill markdown file.
    Skill,
    /// A tool-specific adapter/bootstrap file.
    Adapter,
    /// A command/prompt template.
    Command,
}

/// Returns manifest entries for all ito-skills.
/// Source paths are relative to assets/skills/ (e.g., "ito-proposal/SKILL.md")
/// Dest paths have ito- prefix added if not already present
/// (e.g., "ito-proposal/SKILL.md" remains "ito-proposal/SKILL.md")
/// (e.g., "ito/SKILL.md" -> "ito/SKILL.md" - no double prefix)
fn ito_skills_manifests(skills_dir: &Path) -> Vec<FileManifest> {
    let mut manifests = Vec::new();

    // Get all skill files from embedded assets
    for file in skills_files() {
        let rel_path = file.relative_path;
        // Extract skill name from path (e.g., "ito-proposal/SKILL.md" -> "ito-proposal")
        let parts: Vec<&str> = rel_path.split('/').collect();
        if parts.is_empty() {
            continue;
        }
        let skill_name = parts[0];

        // Build destination path, adding ito- prefix only if not already present
        let dest_skill_name = if skill_name.starts_with("ito") {
            skill_name.to_string()
        } else {
            format!("ito-{}", skill_name)
        };

        let rest = if parts.len() > 1 {
            parts[1..].join("/")
        } else {
            rel_path.to_string()
        };
        let dest = skills_dir.join(format!("{}/{}", dest_skill_name, rest));

        manifests.push(FileManifest {
            source: rel_path.to_string(),
            dest,
            asset_type: AssetType::Skill,
        });
    }

    manifests
}

/// Returns manifest entries for all ito commands.
/// Commands are copied directly to the commands directory with their original names.
fn ito_commands_manifests(commands_dir: &Path) -> Vec<FileManifest> {
    let mut manifests = Vec::new();

    for file in commands_files() {
        let rel_path = file.relative_path;
        manifests.push(FileManifest {
            source: rel_path.to_string(),
            dest: commands_dir.join(rel_path),
            asset_type: AssetType::Command,
        });
    }

    manifests
}

/// Return manifest entries for OpenCode template installation.
///
/// OpenCode stores its configuration under a single directory (typically
/// `~/.config/opencode/`). We install an Ito plugin along with a flat list of
/// skills and commands.
pub fn opencode_manifests(config_dir: &Path) -> Vec<FileManifest> {
    let mut out = Vec::new();

    out.push(FileManifest {
        source: "opencode/ito-skills.js".to_string(),
        dest: config_dir.join("plugins").join("ito-skills.js"),
        asset_type: AssetType::Adapter,
    });

    // Skills go directly under skills/ (flat structure with ito- prefix)
    let skills_dir = config_dir.join("skills");
    out.extend(ito_skills_manifests(&skills_dir));

    // Commands go under commands/
    let commands_dir = config_dir.join("commands");
    out.extend(ito_commands_manifests(&commands_dir));

    out
}

/// Return manifest entries for Claude Code template installation.
pub fn claude_manifests(project_root: &Path) -> Vec<FileManifest> {
    let mut out = vec![
        FileManifest {
            source: "claude/session-start.sh".to_string(),
            dest: project_root.join(".claude").join("session-start.sh"),
            asset_type: AssetType::Adapter,
        },
        FileManifest {
            source: "claude/hooks/ito-audit.sh".to_string(),
            dest: project_root
                .join(".claude")
                .join("hooks")
                .join("ito-audit.sh"),
            asset_type: AssetType::Adapter,
        },
    ];

    // Skills go directly under .claude/skills/ (flat structure with ito- prefix)
    let skills_dir = project_root.join(".claude").join("skills");
    out.extend(ito_skills_manifests(&skills_dir));

    // Commands go under .claude/commands/
    let commands_dir = project_root.join(".claude").join("commands");
    out.extend(ito_commands_manifests(&commands_dir));

    out
}

/// Return manifest entries for Codex template installation.
pub fn codex_manifests(project_root: &Path) -> Vec<FileManifest> {
    let mut out = vec![FileManifest {
        source: "codex/ito-skills-bootstrap.md".to_string(),
        dest: project_root
            .join(".codex")
            .join("instructions")
            .join("ito-skills-bootstrap.md"),
        asset_type: AssetType::Adapter,
    }];

    // Skills go directly under .codex/skills/ (flat structure with ito- prefix)
    let skills_dir = project_root.join(".codex").join("skills");
    out.extend(ito_skills_manifests(&skills_dir));

    // Commands go under .codex/prompts/ (Codex uses "prompts" terminology)
    let commands_dir = project_root.join(".codex").join("prompts");
    out.extend(ito_commands_manifests(&commands_dir));

    out
}

/// Return manifest entries for Pi coding agent template installation.
///
/// Pi gets its own copy of skills and commands under `.pi/` so it is fully
/// self-contained — users can install Pi without OpenCode. The skills and
/// commands are read from the same shared embedded assets used by every harness.
pub fn pi_manifests(project_root: &Path) -> Vec<FileManifest> {
    let mut out = vec![FileManifest {
        source: "pi/ito-skills.ts".to_string(),
        dest: project_root
            .join(".pi")
            .join("extensions")
            .join("ito-skills.ts"),
        asset_type: AssetType::Adapter,
    }];

    // Skills go under .pi/skills/ (flat structure with ito- prefix)
    let skills_dir = project_root.join(".pi").join("skills");
    out.extend(ito_skills_manifests(&skills_dir));

    // Commands go under .pi/commands/
    let commands_dir = project_root.join(".pi").join("commands");
    out.extend(ito_commands_manifests(&commands_dir));

    out
}

/// Return manifest entries for GitHub Copilot template installation.
pub fn github_manifests(project_root: &Path) -> Vec<FileManifest> {
    // Skills go directly under .github/skills/ (flat structure with ito- prefix)
    let skills_dir = project_root.join(".github").join("skills");
    let mut out = ito_skills_manifests(&skills_dir);

    // Commands go under .github/prompts/ (GitHub uses "prompts" terminology)
    // Note: GitHub Copilot uses .prompt.md suffix convention
    let prompts_dir = project_root.join(".github").join("prompts");
    for file in commands_files() {
        let rel_path = file.relative_path;
        // Convert ito-apply.md -> ito-apply.prompt.md for GitHub
        let dest_name = if let Some(stripped) = rel_path.strip_suffix(".md") {
            format!("{stripped}.prompt.md")
        } else {
            rel_path.to_string()
        };
        out.push(FileManifest {
            source: rel_path.to_string(),
            dest: prompts_dir.join(dest_name),
            asset_type: AssetType::Command,
        });
    }

    out
}

/// Install manifests from embedded assets to disk.
///
/// Skill assets that explicitly use worktree Jinja variables are rendered with
/// `worktree_ctx` before writing. Other skill files (which may contain `{{` as
/// user-facing prompt placeholders) are written as-is.
///
/// Every `.md` file that contains an Ito managed block receives a version stamp
/// immediately after `<!-- ITO:START -->` before being written to disk.
pub fn install_manifests(
    manifests: &[FileManifest],
    worktree_ctx: Option<&ito_templates::project_templates::WorktreeTemplateContext>,
    mode: crate::installers::InstallMode,
    opts: &crate::installers::InitOptions,
) -> CoreResult<()> {
    use ito_templates::project_templates::{WorktreeTemplateContext, render_project_template};

    let default_ctx = WorktreeTemplateContext::default();
    let ctx = worktree_ctx.unwrap_or(&default_ctx);

    // Source the version once for all manifests in this batch.
    let version = option_env!("ITO_WORKSPACE_VERSION").unwrap_or(env!("CARGO_PKG_VERSION"));

    for manifest in manifests {
        let raw_bytes = match manifest.asset_type {
            AssetType::Skill => get_skill_file(&manifest.source).ok_or_else(|| {
                CoreError::NotFound(format!(
                    "Skill file not found in embedded assets: {}",
                    manifest.source
                ))
            })?,
            AssetType::Adapter => get_adapter_file(&manifest.source).ok_or_else(|| {
                CoreError::NotFound(format!(
                    "Adapter file not found in embedded assets: {}",
                    manifest.source
                ))
            })?,
            AssetType::Command => get_command_file(&manifest.source).ok_or_else(|| {
                CoreError::NotFound(format!(
                    "Command file not found in embedded assets: {}",
                    manifest.source
                ))
            })?,
        };

        // Render skill templates that opt into worktree Jinja2 variables. We
        // intentionally avoid rendering arbitrary `{{ ... }}` placeholders used
        // by non-template skills (e.g. research prompts).
        let mut should_render_skill = false;
        if manifest.asset_type == AssetType::Skill {
            for line in raw_bytes.split(|b| *b == b'\n') {
                let Ok(line) = std::str::from_utf8(line) else {
                    continue;
                };
                if skill_line_uses_worktree_template_syntax(line) {
                    should_render_skill = true;
                    break;
                }
            }
        }

        let bytes = if should_render_skill {
            render_project_template(raw_bytes, ctx).map_err(|e| {
                CoreError::Validation(format!(
                    "Failed to render skill template {}: {}",
                    manifest.source, e
                ))
            })?
        } else {
            raw_bytes.to_vec()
        };

        // Stamp every managed-block markdown file with the current CLI version.
        let bytes = stamp_managed_markdown(bytes, &manifest.source, version);

        // Markdown manifest entries that contain an Ito-managed block AND
        // belong to an asset type whose update contract is "user content
        // outside the managed block survives" go through the marker-scoped
        // writer. Today that contract applies to skills and commands. Adapter
        // markdown (e.g. the codex bootstrap) is still wholesale-refreshed
        // because adapter content is owned end-to-end by Ito; preserving
        // out-of-marker user edits there is not part of the contract. Shell
        // scripts and other non-markdown manifest entries also stay
        // wholesale-write.
        let asset_supports_marker_scope =
            matches!(manifest.asset_type, AssetType::Skill | AssetType::Command);
        let is_managed_md = asset_supports_marker_scope
            && is_plain_markdown_path(&manifest.source)
            && std::str::from_utf8(&bytes)
                .map(|t| t.contains(ito_templates::ITO_START_MARKER))
                .unwrap_or(false);
        if is_managed_md {
            crate::installers::write_marker_aware_markdown(&manifest.dest, &bytes, mode, opts)?;
        } else {
            if let Some(parent) = manifest.dest.parent() {
                ito_common::io::create_dir_all_std(parent).map_err(|e| {
                    CoreError::io(format!("creating directory {}", parent.display()), e)
                })?;
            }
            ito_common::io::write_std(&manifest.dest, &bytes)
                .map_err(|e| CoreError::io(format!("writing {}", manifest.dest.display()), e))?;
        }
    }
    Ok(())
}

/// True when `path` is a plain `.md` asset (excludes Jinja `.md.j2` templates
/// which are rendered, not installed verbatim). Centralising this guard keeps
/// the stamping and marker-scoping checks in one place.
fn is_plain_markdown_path(path: &str) -> bool {
    path.ends_with(".md") && !path.ends_with(".md.j2")
}

/// Inject a version stamp into `bytes` when the file is a managed-block markdown file.
///
/// Returns the (possibly modified) bytes.  The stamp is applied only when:
/// - the relative path ends in `.md` (not `.md.j2`)
/// - the bytes are valid UTF-8
/// - the content contains `<!-- ITO:START -->`
fn stamp_managed_markdown(bytes: Vec<u8>, rel_path: &str, version: &str) -> Vec<u8> {
    if !is_plain_markdown_path(rel_path) {
        return bytes;
    }

    let Ok(text) = std::str::from_utf8(&bytes) else {
        return bytes;
    };

    if !text.contains(ito_templates::ITO_START_MARKER) {
        return bytes;
    }

    ito_templates::stamp_version(text, version).into_bytes()
}

fn skill_line_uses_worktree_template_syntax(line: &str) -> bool {
    if line.contains("{%") {
        return true;
    }

    // Variable-only templates are supported for the worktree context keys.
    const WORKTREE_VARS: &[&str] = &[
        "{{ enabled",
        "{{ strategy",
        "{{ layout_dir_name",
        "{{ integration_mode",
        "{{ default_branch",
    ];

    for var in WORKTREE_VARS {
        if line.contains(var) {
            return true;
        }
    }
    false
}

#[cfg(test)]
#[path = "distribution_tests.rs"]
mod distribution_tests;