ordinary-modify 0.6.0-pre.14

Project manipulation tool for Ordinary
Documentation
#![doc = include_str!("../README.md")]
#![warn(clippy::all, clippy::pedantic)]
#![allow(clippy::missing_errors_doc)]

// Copyright (C) 2026 Ordinary Labs, LLC.
//
// SPDX-License-Identifier: AGPL-3.0-only

pub mod content;
pub mod project;

use anyhow::bail;
use heck::ToTitleCase;
use ordinary_config::{
    ActionConfig, ActionFfi, ActionFfiSerialization, ActionFfiVersion, ErrorConfig,
    IntegrationConfig, IntegrationProtocol, ModelConfig, OrdinaryConfig, TemplateConfig,
    TemplateFfi, TemplateFfiSerialization, TemplateFfiVersion, TemplateRef, UuidVersion,
};
use std::{io::Write, path::Path};
use tracing::instrument;

#[instrument(err)]
#[allow(
    clippy::too_many_arguments,
    clippy::used_underscore_binding,
    clippy::too_many_lines
)]
pub fn add_action(
    path: &str,
    name: &str,
    lang: &str,
    _access: (),
    _accepts: (),
    _returns: (),
    transactional: &Option<bool>,
    _protected: (),
) -> anyhow::Result<()> {
    let proj_path = Path::new(path);
    let mut app_config = OrdinaryConfig::get(path)?;

    let mut actions = app_config.actions.unwrap_or_default();

    if actions.len() <= 225 {
        let next_idx = actions.len();

        let language = match lang {
            "Rust" | "rust" | "rs" => ordinary_config::ActionLang::Rust,
            "JavaScript" | "javascript" | "JS" | "js" => ordinary_config::ActionLang::JavaScript,
            _ => bail!("language {lang} not yet supported"),
        };

        let action_dir_path = proj_path.join("actions").join(name);
        fs_err::create_dir_all(&action_dir_path)?;

        match language {
            ordinary_config::ActionLang::Rust => {
                let gitignore = "/target";

                let cargo_toml = format!(
                    r#"[workspace]

[package]
name = "action"
version = "0.1.0"
edition = "2024"

[dependencies]
ordinary = {{ path = "../../.ordinary/gen/actions/{name}" }}

[profile.release]
strip = "symbols"
lto = "fat"
opt-level = "z"
codegen-units = 1
panic = "abort"
"#
                );

                let main_rs = r#"use std::error::Error;
use ordinary::{recv_in, send_out};

fn main() -> Result<(), Box<dyn Error>> {
    let _input = recv_in()?;

    ordinary::trace(2, "Hello, Ordinary!")?;

    send_out(())
}
"#;

                let mut gitignore_file = fs_err::File::create(action_dir_path.join(".gitignore"))?;
                gitignore_file.write_all(gitignore.as_bytes())?;

                let mut cargo_toml_file = fs_err::File::create(action_dir_path.join("Cargo.toml"))?;
                cargo_toml_file.write_all(cargo_toml.as_bytes())?;

                fs_err::create_dir_all(action_dir_path.join("src"))?;
                let mut main_rs_file =
                    fs_err::File::create(action_dir_path.join("src").join("main.rs"))?;
                main_rs_file.write_all(main_rs.as_bytes())?;
            }
            ordinary_config::ActionLang::JavaScript => {
                let gitignore = r"/target
node_modules";

                let package_json = r#"{
  "name": "action",
  "version": "0.1.0",
  "main": "index.js",
  "files": [
    "index.js",
    "index.d.ts"
  ],
  "types": "client.d.ts",
  "scripts": {
    "build": "esbuild main.js --bundle --minify --outfile=index.js --target=es2020 --format=iife --global-name=action"
  },
  "devDependencies": {
    "esbuild": "0.25.10"
  }
}
                "#;

                let main_js = r#"export function main(input) {
    console.log("Hello, Ordinary!");
}"#;

                let mut gitignore_file = fs_err::File::create(action_dir_path.join(".gitignore"))?;
                gitignore_file.write_all(gitignore.as_bytes())?;

                let mut package_json_file =
                    fs_err::File::create(action_dir_path.join("package.json"))?;
                package_json_file.write_all(package_json.as_bytes())?;

                let mut main_js_file = fs_err::File::create(action_dir_path.join("main.js"))?;
                main_js_file.write_all(main_js.as_bytes())?;
            }
        }

        actions.push(ActionConfig {
            ffi: ActionFfi {
                version: ActionFfiVersion::V1,
                serialization: ActionFfiSerialization::FlexBufferVector,
            },
            idx: u8::try_from(next_idx)?,
            dir_path: Some(format!("./actions/{name}")),
            name: name.to_string(),
            lang: language,
            access: vec![],
            accepts: ordinary_types::Kind::Void,
            returns: ordinary_types::Kind::Void,
            triggered_by: vec![],
            transactional: *transactional,
            protected: None,
            wasm_opt: None,
            timeout: None,
            cors: None,
            privileged: None,
            variables: None,
        });

        app_config.actions = Some(actions);

        let ordinary_json = serde_json::to_string_pretty(&app_config)?;

        let mut file = fs_err::File::create(proj_path.join("ordinary.json"))?;
        file.write_all(ordinary_json.as_bytes())?;
    } else {
        tracing::error!("cannot support more than 255 actions for a single project.");
    }

    Ok(())
}

#[allow(clippy::too_many_arguments, clippy::too_many_lines)]
#[instrument(skip_all, err)]
pub fn add_template(
    path: &str,
    name: &str,
    route: &str,
    mime: &str,
    head_block: &str,
    header_block: &str,
    footer_block: &str,
    is_error: bool,
    globals: Option<Vec<String>>,
    content_refs: Option<Vec<TemplateRef>>,
) -> anyhow::Result<()> {
    let proj_path = Path::new(path);
    let mut app_config = OrdinaryConfig::get(path)?;

    let mut templates = app_config.templates.unwrap_or_default();

    if templates.len() <= 255 {
        let next_idx = templates.len();

        let cache = None;

        let file_ext = match mime {
            "text/html" | "text/html; charset=utf-8" => "html",
            "text/xml" | "application/rss+xml" => "xml",
            "text/plain" | "text/plain; charset=utf-8" => "txt",
            _ => "",
        };

        fs_err::create_dir_all(proj_path.join("templates"))?;

        let file_name = format!("{name}.{file_ext}");
        let mut file = fs_err::File::create(proj_path.join("templates").join(&file_name))?;

        let header_block = if header_block.is_empty() {
            r#"<header>
                <a href="/">{{ canonical }}</a>
            </header>"#
        } else {
            header_block
        };

        let footer_block = if footer_block.is_empty() {
            r#"<footer>
                © 2026 Your Name | powered by&nbsp;<a href="https://codeberg.org/ordinarylabs/Ordinary">Ordinary</a>
            </footer>"#
        } else {
            footer_block
        };

        // todo: have a default file for each extension/mime type
        if mime == "text/html" {
            let main_block = if is_error {
                "<h1>Status Code {{ error.code }}</h1>
                <p>{{ error.message }}</p>"
                    .to_string()
            } else {
                format!("<h1>{}</h1>", name.to_title_case())
            };

            let default_html = format!(
                r#"<!DOCTYPE html>
        <html lang="en">

        <head>
            <meta charset="UTF-8">
            <meta name="viewport" content="width=device-width, initial-scale=1.0">

            <title>{name} | {{{{ canonical }}}}</title>
            <meta name="generator" content="{{{{ generator }}}}">
        {head_block}
        </head>

        <body>
            {header_block}
            <main>
                {main_block}
            </main>
            {footer_block}
        </body>
        "#,
            );
            file.write_all(default_html.as_bytes())?;
            file.flush()?;
        } else {
            file.write_all(&[][..])?;
            file.flush()?;
        }

        templates.push(TemplateConfig {
            ffi: TemplateFfi {
                version: TemplateFfiVersion::V1,
                serialization: TemplateFfiSerialization::FlexBufferVector,
            },
            idx: u8::try_from(next_idx)?,
            name: name.to_string(),
            // todo: verify that route begins with "/" and is valid HTTP route
            route: route.to_string(),
            // todo: check against valid mime types
            mime: mime.to_string(),
            cache,
            path: Some(format!("./templates/{file_name}")),
            globals,
            content: content_refs,
            ..Default::default()
        });

        app_config.templates = Some(templates);
        if is_error {
            app_config.error = Some(ErrorConfig {
                template: Some(name.to_string()),
                asset: None,
            });
        }

        let ordinary_json = serde_json::to_string_pretty(&app_config)?;

        let mut file = fs_err::File::create(proj_path.join("ordinary.json"))?;
        file.write_all(ordinary_json.as_bytes())?;
    } else {
        tracing::error!("cannot support more than 255 templates for a single project.");
    }

    Ok(())
}

#[instrument(err)]
pub fn add_integration(
    path: &str,
    name: &str,
    endpoint: &str,
    protocol: &str,
) -> anyhow::Result<()> {
    let proj_path = Path::new(path);
    let mut app_config = OrdinaryConfig::get(path)?;

    let mut integrations = app_config.integrations.unwrap_or_default();

    if integrations.len() <= 255 {
        let next_idx = integrations.len();

        integrations.push(IntegrationConfig {
            idx: u8::try_from(next_idx)?,
            name: name.to_string(),
            protocol: match protocol {
                "JSON" => IntegrationProtocol::Http {
                    method: "GET".to_string(),
                    headers: vec![],
                    send_encoding: ordinary_config::IntegrationProtocolHttpEncoding::Text,
                    recv_encoding: ordinary_config::IntegrationProtocolHttpEncoding::Text,
                },
                _ => bail!("invalid protocol"),
            },
            endpoint: endpoint.to_string(),
            send: ordinary_types::Kind::Json,
            recv: ordinary_types::Kind::Json,
            secrets: None,
            timeout: None,
        });

        app_config.integrations = Some(integrations);

        let ordinary_json = serde_json::to_string_pretty(&app_config)?;

        let mut file = fs_err::File::create(proj_path.join("ordinary.json"))?;
        file.write_all(ordinary_json.as_bytes())?;
    } else {
        tracing::error!("cannot support more than 255 integrations for a single project.");
    }

    Ok(())
}

#[instrument(err)]
pub fn add_model(path: &str, name: &str, uuid: Option<&str>) -> anyhow::Result<()> {
    let proj_path = Path::new(path);
    let mut app_config = OrdinaryConfig::get(path)?;

    let mut models = app_config.models.unwrap_or_default();

    if models.len() <= 255 {
        let next_idx = models.len();

        models.push(ModelConfig {
            idx: u8::try_from(next_idx)?,
            name: name.to_string(),
            fields: vec![],
            uuid: match uuid {
                Some(uuid) => match uuid {
                    "v4" => Some(UuidVersion::V4),
                    "v7" => Some(UuidVersion::V7),
                    _ => bail!("invalid UUID version"),
                },
                None => None,
            },
        });

        app_config.models = Some(models);

        let ordinary_json = serde_json::to_string_pretty(&app_config)?;

        let mut file = fs_err::File::create(proj_path.join("ordinary.json"))?;
        file.write_all(ordinary_json.as_bytes())?;
    } else {
        tracing::error!("cannot support more than 255 models for a single project.");
    }

    Ok(())
}