diff --git a/Cargo.lock b/Cargo.lock index 933eb3301..267065a43 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -891,6 +891,12 @@ version = "0.31.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f" +[[package]] +name = "glob" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8d1add55171497b4705a648c6b583acafb01d58050a51727785f0b2c8e0a2b2" + [[package]] name = "group" version = "0.13.0" @@ -1214,6 +1220,16 @@ dependencies = [ "thiserror 1.0.69", ] +[[package]] +name = "icp-canister" +version = "0.1.0" +dependencies = [ + "camino", + "icp-fs", + "serde", + "snafu", +] + [[package]] name = "icp-cli" version = "0.1.0" @@ -1222,8 +1238,10 @@ dependencies = [ "camino", "camino-tempfile", "clap", + "icp-canister", "icp-fs", "icp-network", + "icp-project", "predicates", "reqwest", "snafu", @@ -1237,6 +1255,7 @@ dependencies = [ "camino", "serde", "serde_json", + "serde_yaml", "snafu", ] @@ -1264,6 +1283,19 @@ dependencies = [ "uuid", ] +[[package]] +name = "icp-project" +version = "0.1.0" +dependencies = [ + "camino", + "glob", + "icp-canister", + "icp-fs", + "icp-network", + "serde", + "snafu", +] + [[package]] name = "icu_collections" version = "2.0.0" @@ -2363,6 +2395,19 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_yaml" +version = "0.9.34+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" +dependencies = [ + "indexmap", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + [[package]] name = "sha2" version = "0.9.9" @@ -2950,6 +2995,12 @@ version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + [[package]] name = "untrusted" version = "0.9.0" diff --git a/Cargo.toml b/Cargo.toml index 22efcf31c..7f0a7bc63 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,9 +1,11 @@ [workspace] members = [ "bin/icp-cli", + "lib/icp-canister", + "lib/icp-fs", "lib/icp-identity", "lib/icp-network", - "lib/icp-fs", + "lib/icp-project", ] resolver = "3" @@ -12,6 +14,7 @@ resolver = "3" camino = { version = "1.1.9", features = ["serde1"] } candid = "0.10.14" fd-lock = "4.0.4" +glob = "0.3.2" hex = "0.4.3" ic-agent = { version = "0.40.1" } pocket-ic = "9.0.0" @@ -20,6 +23,7 @@ reqwest = { version = "0.12.15", default-features = false, features = [ ] } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" +serde_yaml = "0.9.34" snafu = "0.8.5" tempfile = "3" time = "0.3.9" diff --git a/bin/icp-cli/Cargo.toml b/bin/icp-cli/Cargo.toml index f0a2ac2d6..a245e49d3 100644 --- a/bin/icp-cli/Cargo.toml +++ b/bin/icp-cli/Cargo.toml @@ -11,8 +11,10 @@ path = "src/main.rs" [dependencies] camino = { workspace = true } clap = { version = "4.5.35", features = ["derive"] } -icp-network = { path = "../../lib/icp-network" } +icp-canister = { path = "../../lib/icp-canister" } icp-fs = { path = "../../lib/icp-fs" } +icp-network = { path = "../../lib/icp-network" } +icp-project = { path = "../../lib/icp-project" } snafu = { workspace = true } tokio = { workspace = true } diff --git a/bin/icp-cli/src/commands.rs b/bin/icp-cli/src/commands.rs index cdfe90e0f..cec84bbb1 100644 --- a/bin/icp-cli/src/commands.rs +++ b/bin/icp-cli/src/commands.rs @@ -1,7 +1,8 @@ -use crate::commands::network::NetworkCommandError; +use crate::commands::{build::BuildCommandError, network::NetworkCommandError}; use clap::{Parser, Subcommand}; use snafu::Snafu; +mod build; mod network; #[derive(Parser, Debug)] @@ -12,11 +13,13 @@ pub struct Cli { #[derive(Subcommand, Debug)] pub enum Subcmd { + Build(build::Cmd), Network(network::Cmd), } pub async fn dispatch(cli: Cli) -> Result<(), DispatchError> { match cli.subcommand { + Subcmd::Build(opts) => build::exec(opts).await?, Subcmd::Network(opts) => network::dispatch(opts).await?, } Ok(()) @@ -24,6 +27,9 @@ pub async fn dispatch(cli: Cli) -> Result<(), DispatchError> { #[derive(Debug, Snafu)] pub enum DispatchError { + #[snafu(transparent)] + Build { source: BuildCommandError }, + #[snafu(transparent)] Network { source: NetworkCommandError }, } diff --git a/bin/icp-cli/src/commands/build.rs b/bin/icp-cli/src/commands/build.rs new file mode 100644 index 000000000..2eb42f09a --- /dev/null +++ b/bin/icp-cli/src/commands/build.rs @@ -0,0 +1,47 @@ +use clap::Parser; +use icp_canister::model::{CanisterManifest, LoadCanisterManifestError}; +use icp_project::directory::{FindProjectError, ProjectDirectory}; +use icp_project::model::{LoadProjectManifestError, ProjectManifest}; +use snafu::Snafu; + +#[derive(Parser, Debug)] +pub struct Cmd; + +pub async fn exec(_cmd: Cmd) -> Result<(), BuildCommandError> { + // Project + let pd = ProjectDirectory::find()?.ok_or(BuildCommandError::ProjectNotFound)?; + + // Project Structure (paths, etc) + let pds = pd.structure(); + + // Load + let pm = ProjectManifest::load(pds)?; + + // List canisters in project + let mut cs = Vec::new(); + + for c in pm.canisters { + let cm = CanisterManifest::from_file(pds.canister_yaml_path(&c))?; + cs.push(cm); + } + + // Build canisters + println!("{cs:?}"); + + Ok(()) +} + +#[derive(Debug, Snafu)] +pub enum BuildCommandError { + #[snafu(transparent)] + FindProjectError { source: FindProjectError }, + + #[snafu(display("no project (icp.yaml) found in current directory or its parents"))] + ProjectNotFound, + + #[snafu(transparent)] + ProjectLoad { source: LoadProjectManifestError }, + + #[snafu(transparent)] + CanisterLoad { source: LoadCanisterManifestError }, +} diff --git a/bin/icp-cli/src/commands/network/run.rs b/bin/icp-cli/src/commands/network/run.rs index 7b34eee0f..125330a45 100644 --- a/bin/icp-cli/src/commands/network/run.rs +++ b/bin/icp-cli/src/commands/network/run.rs @@ -1,10 +1,7 @@ -use crate::project::directory::ProjectDirectory; -use crate::{ - commands::network::run::RunNetworkCommandError::ProjectNotFound, - project::directory::FindProjectError, -}; +use crate::commands::network::run::RunNetworkCommandError::ProjectNotFound; use clap::Parser; use icp_network::{ManagedNetworkModel, RunNetworkError, run_network}; +use icp_project::directory::{FindProjectError, ProjectDirectory}; use snafu::Snafu; /// Run the local network diff --git a/bin/icp-cli/src/main.rs b/bin/icp-cli/src/main.rs index 579157bb5..e87744e2b 100644 --- a/bin/icp-cli/src/main.rs +++ b/bin/icp-cli/src/main.rs @@ -2,7 +2,6 @@ use crate::commands::Cli; use clap::Parser; mod commands; -mod project; #[tokio::main] async fn main() { diff --git a/lib/icp-canister/Cargo.toml b/lib/icp-canister/Cargo.toml new file mode 100644 index 000000000..ac7dd7277 --- /dev/null +++ b/lib/icp-canister/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "icp-canister" +version = "0.1.0" +edition = "2024" + +[dependencies] +camino = { workspace = true } +icp-fs = { "path" = "../icp-fs" } +serde = { workspace = true } +snafu = { workspace = true } diff --git a/lib/icp-canister/src/lib.rs b/lib/icp-canister/src/lib.rs new file mode 100644 index 000000000..65880be0e --- /dev/null +++ b/lib/icp-canister/src/lib.rs @@ -0,0 +1 @@ +pub mod model; diff --git a/lib/icp-canister/src/model.rs b/lib/icp-canister/src/model.rs new file mode 100644 index 000000000..9ca69d3d4 --- /dev/null +++ b/lib/icp-canister/src/model.rs @@ -0,0 +1,85 @@ +use camino::Utf8Path; +use icp_fs::yaml::{LoadYamlFileError, load_yaml_file}; +use serde::Deserialize; +use snafu::Snafu; + +/// Configuration for a Rust-based canister build adapter. +#[derive(Debug, Deserialize)] +pub struct RustAdapter { + /// The name of the Cargo package to build. + pub package: String, +} + +/// Configuration for a Motoko-based canister build adapter. +#[derive(Debug, Deserialize)] +pub struct MotokoAdapter { + /// Optional path to the main Motoko source file. + /// If omitted, a default like `main.mo` may be assumed. + #[serde(default)] + pub main: Option, +} + +/// Configuration for a custom canister build adapter. +#[derive(Debug, Deserialize)] +pub struct CustomAdapter { + /// Path to a script or executable used to build the canister. + pub script: String, +} + +/// Identifies the type of adapter used to build the canister, +/// along with its configuration. +/// +/// The adapter type is specified via the `type` field in the YAML file. +/// For example: +/// +/// ```yaml +/// type: rust +/// package: my_canister +/// ``` +#[derive(Debug, Deserialize)] +#[serde(tag = "type", rename_all = "lowercase")] +pub enum Adapter { + /// A canister written in Rust. + Rust(RustAdapter), + + /// A canister written in Motoko. + Motoko(MotokoAdapter), + + /// A canister built using a custom script or command. + Custom(CustomAdapter), +} + +/// Describes how the canister should be built into WebAssembly, +/// including the adapter responsible for the build. +#[derive(Debug, Deserialize)] +pub struct Build { + pub adapter: Adapter, +} + +/// Represents the manifest describing a single canister, +/// including its name and how it should be built. +#[derive(Debug, Deserialize)] +pub struct CanisterManifest { + /// Name of the canister described by this manifest. + pub name: String, + + /// Build configuration for producing the canister's WebAssembly. + pub build: Build, +} + +impl CanisterManifest { + pub fn from_file>(path: P) -> Result { + let path = path.as_ref(); + + // Load + let cm: CanisterManifest = load_yaml_file(path)?; + + Ok(cm) + } +} + +#[derive(Debug, Snafu)] +pub enum LoadCanisterManifestError { + #[snafu(transparent)] + Parse { source: LoadYamlFileError }, +} diff --git a/lib/icp-fs/Cargo.toml b/lib/icp-fs/Cargo.toml index 1542a2c41..840e5e656 100644 --- a/lib/icp-fs/Cargo.toml +++ b/lib/icp-fs/Cargo.toml @@ -7,4 +7,5 @@ edition = "2024" camino = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } +serde_yaml = { workspace = true } snafu = { workspace = true } diff --git a/lib/icp-fs/src/lib.rs b/lib/icp-fs/src/lib.rs index ba52be046..e1d65114a 100644 --- a/lib/icp-fs/src/lib.rs +++ b/lib/icp-fs/src/lib.rs @@ -1,2 +1,3 @@ pub mod fs; pub mod json; +pub mod yaml; diff --git a/lib/icp-fs/src/yaml.rs b/lib/icp-fs/src/yaml.rs new file mode 100644 index 000000000..3e2413486 --- /dev/null +++ b/lib/icp-fs/src/yaml.rs @@ -0,0 +1,25 @@ +use camino::{Utf8Path, Utf8PathBuf}; +use snafu::prelude::*; + +use crate::fs::{ReadFileError, read}; + +#[derive(Snafu, Debug)] +pub enum LoadYamlFileError { + #[snafu(display("failed to parse {path} as yaml"))] + Parse { + path: Utf8PathBuf, + source: serde_yaml::Error, + }, + + #[snafu(transparent)] + Read { source: ReadFileError }, +} + +pub fn load_yaml_file serde::de::Deserialize<'a>>( + path: impl AsRef, +) -> Result { + let path = path.as_ref(); + let content = read(path)?; + + serde_yaml::from_slice(content.as_ref()).context(ParseSnafu { path }) +} diff --git a/lib/icp-project/Cargo.toml b/lib/icp-project/Cargo.toml new file mode 100644 index 000000000..8535bd879 --- /dev/null +++ b/lib/icp-project/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "icp-project" +version = "0.1.0" +edition = "2024" + +[dependencies] +camino = { workspace = true } +glob = { workspace = true } +icp-canister = { path = "../icp-canister" } +icp-fs = { "path" = "../icp-fs" } +icp-network = { path = "../icp-network" } +serde = { workspace = true } +snafu = { workspace = true } diff --git a/bin/icp-cli/src/project/directory.rs b/lib/icp-project/src/directory.rs similarity index 95% rename from bin/icp-cli/src/project/directory.rs rename to lib/icp-project/src/directory.rs index 6413e74c7..de4405f05 100644 --- a/bin/icp-cli/src/project/directory.rs +++ b/lib/icp-project/src/directory.rs @@ -1,4 +1,4 @@ -use crate::project::structure::ProjectDirectoryStructure; +use crate::structure::ProjectDirectoryStructure; use camino::Utf8PathBuf; use icp_network::NetworkDirectory; use snafu::{ResultExt, Snafu}; diff --git a/bin/icp-cli/src/project.rs b/lib/icp-project/src/lib.rs similarity index 71% rename from bin/icp-cli/src/project.rs rename to lib/icp-project/src/lib.rs index 329ccc42c..994d078dc 100644 --- a/bin/icp-cli/src/project.rs +++ b/lib/icp-project/src/lib.rs @@ -1,2 +1,3 @@ pub mod directory; +pub mod model; pub mod structure; diff --git a/lib/icp-project/src/model.rs b/lib/icp-project/src/model.rs new file mode 100644 index 000000000..5d92ee718 --- /dev/null +++ b/lib/icp-project/src/model.rs @@ -0,0 +1,112 @@ +use crate::structure::ProjectDirectoryStructure; +use camino::{Utf8Path, Utf8PathBuf}; +use icp_fs::yaml::{LoadYamlFileError, load_yaml_file}; +use serde::Deserialize; +use snafu::{ResultExt, Snafu}; + +/// Provides the default glob pattern for locating canister manifests +/// when the `canisters` field is not explicitly specified in the YAML. +fn default_canisters() -> Vec { + ["canisters/*"].iter().map(Utf8PathBuf::from).collect() +} + +/// Provides the default glob pattern for locating network definition files +/// when the `networks` field is not explicitly specified in the YAML. +fn default_networks() -> Vec { + ["networks/*"].iter().map(Utf8PathBuf::from).collect() +} + +/// Represents the manifest for an ICP project, typically loaded from `icp.yaml`. +/// A project is a repository or directory grouping related canisters and network definitions. +#[derive(Debug, Deserialize)] +pub struct ProjectManifest { + /// List of canister manifests belonging to this project. + /// Supports glob patterns to specify multiple canister YAML files. + #[serde(default = "default_canisters")] + pub canisters: Vec, + + /// List of network definition files relevant to the project. + /// Supports glob patterns to reference multiple network config files. + #[serde(default = "default_networks")] + pub networks: Vec, +} + +impl ProjectManifest { + /// Loads the project manifest (`project.yaml`) and resolves canister paths. + /// + /// This function utilizes the provided [`ProjectDirectoryStructure`] to locate + /// the `project.yaml` file and then identify all canister directories + /// referenced within it. + /// + /// # Canister Path Resolution + /// + /// Currently, all paths specified in the `canisters` field of the manifest + /// are treated as glob patterns. This means that even if a direct path to a + /// canister directory is provided (e.g., `canisters/my_canister`), it will + /// be processed as a glob. + /// + /// A consequence of this glob-based approach is that if an explicitly + /// specified canister path does not contain a `canister.yaml` file (thus, + /// not being a valid canister directory according to `ProjectDirectoryStructure`), + /// it will be silently ignored rather than causing an error. + /// + /// **Future Improvement:** This behavior should be changed. In a future + /// version, if a path in the `canisters` list is *not* a glob pattern (i.e., + /// it's an explicit path), and that path does not point to a valid canister + /// directory (i.e., it's missing a `canister.yaml` or is not a directory), + /// the loading process should raise an error. This will provide clearer + /// feedback for misconfigured manifests. + pub fn load(pds: &ProjectDirectoryStructure) -> Result { + let mpath = pds.project_yaml_path(); + let mpath: &Utf8Path = mpath.as_ref(); + + // Load + let mut pm: ProjectManifest = load_yaml_file(mpath)?; + + // Canisters + let mut cs = Vec::new(); + + for pattern in pm.canisters { + let matches = glob::glob(pds.root().join(&pattern).as_str()) + .context(GlobPatternSnafu { pattern })?; + + for cpath in matches { + let cpath = cpath.context(GlobWalkSnafu { path: mpath })?; + + let path: Utf8PathBuf = cpath.try_into()?; + + // Skip non-canister directories + if !pds.canister_yaml_path(&path).exists() { + continue; + } + + cs.push(path); + } + } + + pm.canisters = cs; + + Ok(pm) + } +} + +#[derive(Debug, Snafu)] +pub enum LoadProjectManifestError { + #[snafu(transparent)] + Parse { source: LoadYamlFileError }, + + #[snafu(transparent)] + InvalidPathUtf8 { source: camino::FromPathBufError }, + + #[snafu(display("failed to glob pattern {pattern}"))] + GlobPattern { + source: glob::PatternError, + pattern: String, + }, + + #[snafu(display("failed to glob pattern in {path}"))] + GlobWalk { + source: glob::GlobError, + path: Utf8PathBuf, + }, +} diff --git a/bin/icp-cli/src/project/structure.rs b/lib/icp-project/src/structure.rs similarity index 84% rename from bin/icp-cli/src/project/structure.rs rename to lib/icp-project/src/structure.rs index 3f3b8853a..a548d0dfe 100644 --- a/bin/icp-cli/src/project/structure.rs +++ b/lib/icp-project/src/structure.rs @@ -30,4 +30,8 @@ impl ProjectDirectoryStructure { pub fn network_root(&self, network_name: &str) -> Utf8PathBuf { self.work_dir().join("networks").join(network_name) } + + pub fn canister_yaml_path(&self, canister_dir: &Utf8Path) -> Utf8PathBuf { + self.root.join(canister_dir).join("canister.yaml") + } }