diff --git a/docs/book/src/forc/manifest_reference.md b/docs/book/src/forc/manifest_reference.md index e9be681e5c8..0757f13e213 100644 --- a/docs/book/src/forc/manifest_reference.md +++ b/docs/book/src/forc/manifest_reference.md @@ -11,6 +11,7 @@ The `Forc.toml` (the _manifest_ file) is a compulsory file for each package and * For the recommended way of selecting an entry point of large libraries please take a look at: [Libraries](./../sway-program-types/libraries.md) * `implicit-std` - Controls whether provided `std` version (with the current `forc` version) will get added as a dependency _implicitly_. _Unless you know what you are doing, leave this as default._ * `forc-version` - The minimum forc version required for this project to work properly. + * `metadata` - Metadata for the project; can be used by tools which would like to store package configuration in `Forc.toml`. * [`[dependencies]`](#the-dependencies-section) — Defines the dependencies. * `[network]` — Defines a network for forc to interact with. @@ -41,8 +42,85 @@ entry = "main.sw" organization = "Fuel_Labs" license = "Apache-2.0" name = "wallet_contract" + +[project.metadata] +indexing = { namespace = "counter-contract", schema_path = "out/release/counter-contract-abi.json" } +``` + +### Metadata Section in `Forc.toml` + +The `[project.metadata]` section provides a dedicated space for external tools and plugins to store their configuration in `Forc.toml`. The metadata key names are arbitrary and do not need to match the tool's name. + +#### Workspace vs Project Metadata + +Metadata can be defined at two levels: + +Workspace level - defined in the workspace\'s root `Forc.toml`: + +```toml +[workspace.metadata] +my_tool = { shared_setting = "value" } +``` + +Project level - defined in individual project\'s `Forc.toml`: + +```toml +[project.metadata.any_name_here] +option1 = "value" +option2 = "value" + +[project.metadata.my_custom_config] +setting1 = "value" +setting2 = "value" +``` + +Example for an indexing tool: + +```toml +[project.metadata.indexing] +namespace = "counter-contract" +schema_path = "out/release/counter-contract-abi.json" ``` +When both workspace and project metadata exist: + +* Project-level metadata should take precedence over workspace metadata +* Tools can choose to merge workspace and project settings +* Consider documenting your tool's metadata inheritance behavior + +#### Guidelines for Plugin Developers + +Best Practices + +* Choose clear, descriptive metadata key names +* Document the exact metadata key name your tool expects +* Don't require `Forc.toml` if tool can function without it +* Consider using TOML format for dedicated config files +* Specify how your tool handles workspace vs project metadata + +Implementation Notes + +* The metadata section is optional +* Forc does not parse metadata contents +* Plugin developers handle their own configuration parsing +* Choose unique metadata keys to avoid conflicts with other tools + +#### Example Use Cases + +* Documentation generation settings +* Formatter configurations +* Debugger options +* Wallet integration +* Contract indexing +* Testing frameworks + +This allows for a streamlined developer experience while maintaining clear separation between core Forc functionality and third-party tools. + +#### External Tooling Examples + +* [forc-index-ts](https://github.com/FuelLabs/example-forc-plugins/tree/master/forc-index-ts): A TypeScript CLI tool for parsing `Forc.toml` metadata to read contract ABI JSON file. +* [forc-index-rs](https://github.com/FuelLabs/example-forc-plugins/tree/master/forc-index-rs): A Rust CLI tool for parsing `Forc.toml` metadata to read contract ABI JSON file. + ## The `[dependencies]` section The following fields can be provided with a dependency: diff --git a/forc-pkg/src/manifest/mod.rs b/forc-pkg/src/manifest/mod.rs index 5ce50d65804..c20effec0fb 100644 --- a/forc-pkg/src/manifest/mod.rs +++ b/forc-pkg/src/manifest/mod.rs @@ -166,7 +166,7 @@ impl TryInto for ManifestFile { type PatchMap = BTreeMap; /// A [PackageManifest] that was deserialized from a file at a particular path. -#[derive(Clone, Debug, PartialEq, Eq)] +#[derive(Clone, Debug, PartialEq)] pub struct PackageManifestFile { /// The deserialized `Forc.toml`. manifest: PackageManifest, @@ -175,7 +175,7 @@ pub struct PackageManifestFile { } /// A direct mapping to a `Forc.toml`. -#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)] #[serde(rename_all = "kebab-case")] pub struct PackageManifest { pub project: Project, @@ -189,7 +189,7 @@ pub struct PackageManifest { pub proxy: Option, } -#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)] #[serde(rename_all = "kebab-case")] pub struct Project { pub authors: Option>, @@ -202,6 +202,7 @@ pub struct Project { pub forc_version: Option, #[serde(default)] pub experimental: HashMap, + pub metadata: Option, } #[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] @@ -850,6 +851,7 @@ pub struct WorkspaceManifest { #[serde(rename_all = "kebab-case")] pub struct Workspace { pub members: Vec, + pub metadata: Option, } impl WorkspaceManifestFile { @@ -1330,4 +1332,287 @@ mod tests { assert!(dependency_details_git_rev.validate().is_ok()); assert!(dependency_details_ipfs.validate().is_ok()); } + + #[test] + fn test_project_with_null_metadata() { + let project = Project { + authors: Some(vec!["Test Author".to_string()]), + name: "test-project".to_string(), + organization: None, + license: "Apache-2.0".to_string(), + entry: "main.sw".to_string(), + implicit_std: None, + forc_version: None, + experimental: HashMap::new(), + metadata: Some(toml::Value::from(toml::value::Table::new())), + }; + + let serialized = toml::to_string(&project).unwrap(); + let deserialized: Project = toml::from_str(&serialized).unwrap(); + + assert_eq!(project.name, deserialized.name); + assert_eq!(project.metadata, deserialized.metadata); + } + + #[test] + fn test_project_without_metadata() { + let project = Project { + authors: Some(vec!["Test Author".to_string()]), + name: "test-project".to_string(), + organization: None, + license: "Apache-2.0".to_string(), + entry: "main.sw".to_string(), + implicit_std: None, + forc_version: None, + experimental: HashMap::new(), + metadata: None, + }; + + let serialized = toml::to_string(&project).unwrap(); + let deserialized: Project = toml::from_str(&serialized).unwrap(); + + assert_eq!(project.name, deserialized.name); + assert_eq!(project.metadata, deserialized.metadata); + assert_eq!(project.metadata, None); + } + + #[test] + fn test_project_metadata_from_toml() { + let toml_str = r#" + name = "test-project" + license = "Apache-2.0" + entry = "main.sw" + authors = ["Test Author"] + + [metadata] + description = "A test project" + version = "1.0.0" + homepage = "https://example.com" + documentation = "https://docs.example.com" + repository = "https://github.com/example/test-project" + keywords = ["test", "project"] + categories = ["test"] + "#; + + let project: Project = toml::from_str(toml_str).unwrap(); + assert!(project.metadata.is_some()); + + let metadata = project.metadata.unwrap(); + let table = metadata.as_table().unwrap(); + + assert_eq!( + table.get("description").unwrap().as_str().unwrap(), + "A test project" + ); + assert_eq!(table.get("version").unwrap().as_str().unwrap(), "1.0.0"); + assert_eq!( + table.get("homepage").unwrap().as_str().unwrap(), + "https://example.com" + ); + + let keywords = table.get("keywords").unwrap().as_array().unwrap(); + assert_eq!(keywords[0].as_str().unwrap(), "test"); + assert_eq!(keywords[1].as_str().unwrap(), "project"); + } + + #[test] + fn test_project_with_invalid_metadata() { + // Test with invalid TOML syntax - unclosed table + let invalid_toml = r#" + name = "test-project" + license = "Apache-2.0" + entry = "main.sw" + + [metadata + description = "Invalid TOML" + "#; + + let result: Result = toml::from_str(invalid_toml); + assert!(result.is_err()); + + // Test with invalid TOML syntax - invalid key + let invalid_toml = r#" + name = "test-project" + license = "Apache-2.0" + entry = "main.sw" + + [metadata] + ] = "Invalid key" + "#; + + let result: Result = toml::from_str(invalid_toml); + assert!(result.is_err()); + + // Test with duplicate keys + let invalid_toml = r#" + name = "test-project" + license = "Apache-2.0" + entry = "main.sw" + + [metadata] + nested = { key = "value1" } + + [metadata.nested] + key = "value2" + "#; + + let result: Result = toml::from_str(invalid_toml); + assert!(result.is_err()); + assert!(result + .err() + .unwrap() + .to_string() + .contains("duplicate key `nested` in table `metadata`")); + } + + #[test] + fn test_metadata_roundtrip() { + let original_toml = r#" + name = "test-project" + license = "Apache-2.0" + entry = "main.sw" + + [metadata] + boolean = true + integer = 42 + float = 3.12 + string = "value" + array = [1, 2, 3] + mixed_array = [1, "two", true] + + [metadata.nested] + key = "value2" + "#; + + let project: Project = toml::from_str(original_toml).unwrap(); + let serialized = toml::to_string(&project).unwrap(); + let deserialized: Project = toml::from_str(&serialized).unwrap(); + + // Verify that the metadata is preserved + assert_eq!(project.metadata, deserialized.metadata); + + // Verify all types were preserved + let table_val = project.metadata.unwrap(); + let table = table_val.as_table().unwrap(); + assert!(table.get("boolean").unwrap().as_bool().unwrap()); + assert_eq!(table.get("integer").unwrap().as_integer().unwrap(), 42); + assert_eq!(table.get("float").unwrap().as_float().unwrap(), 3.12); + assert_eq!(table.get("string").unwrap().as_str().unwrap(), "value"); + assert_eq!(table.get("array").unwrap().as_array().unwrap().len(), 3); + assert!(table.get("nested").unwrap().as_table().is_some()); + } + + #[test] + fn test_workspace_with_metadata() { + let toml_str = r#" + [workspace] + members = ["package1", "package2"] + + [workspace.metadata] + description = "A test workspace" + version = "1.0.0" + authors = ["Test Author"] + homepage = "https://example.com" + + [workspace.metadata.ci] + workflow = "main" + timeout = 3600 + "#; + + let manifest: WorkspaceManifest = toml::from_str(toml_str).unwrap(); + assert!(manifest.workspace.metadata.is_some()); + + let metadata = manifest.workspace.metadata.unwrap(); + let table = metadata.as_table().unwrap(); + + assert_eq!( + table.get("description").unwrap().as_str().unwrap(), + "A test workspace" + ); + assert_eq!(table.get("version").unwrap().as_str().unwrap(), "1.0.0"); + + let ci = table.get("ci").unwrap().as_table().unwrap(); + assert_eq!(ci.get("workflow").unwrap().as_str().unwrap(), "main"); + assert_eq!(ci.get("timeout").unwrap().as_integer().unwrap(), 3600); + } + + #[test] + fn test_workspace_without_metadata() { + let toml_str = r#" + [workspace] + members = ["package1", "package2"] + "#; + + let manifest: WorkspaceManifest = toml::from_str(toml_str).unwrap(); + assert!(manifest.workspace.metadata.is_none()); + } + + #[test] + fn test_workspace_empty_metadata() { + let toml_str = r#" + [workspace] + members = ["package1", "package2"] + + [workspace.metadata] + "#; + + let manifest: WorkspaceManifest = toml::from_str(toml_str).unwrap(); + assert!(manifest.workspace.metadata.is_some()); + let metadata = manifest.workspace.metadata.unwrap(); + assert!(metadata.as_table().unwrap().is_empty()); + } + + #[test] + fn test_workspace_complex_metadata() { + let toml_str = r#" + [workspace] + members = ["package1", "package2"] + + [workspace.metadata] + numbers = [1, 2, 3] + strings = ["a", "b", "c"] + mixed = [1, "two", true] + + [workspace.metadata.nested] + key = "value" + + [workspace.metadata.nested.deep] + another = "value" + "#; + + let manifest: WorkspaceManifest = toml::from_str(toml_str).unwrap(); + let metadata = manifest.workspace.metadata.unwrap(); + let table = metadata.as_table().unwrap(); + + assert!(table.get("numbers").unwrap().as_array().is_some()); + assert!(table.get("strings").unwrap().as_array().is_some()); + assert!(table.get("mixed").unwrap().as_array().is_some()); + + let nested = table.get("nested").unwrap().as_table().unwrap(); + assert_eq!(nested.get("key").unwrap().as_str().unwrap(), "value"); + + let deep = nested.get("deep").unwrap().as_table().unwrap(); + assert_eq!(deep.get("another").unwrap().as_str().unwrap(), "value"); + } + + #[test] + fn test_workspace_metadata_roundtrip() { + let original = WorkspaceManifest { + workspace: Workspace { + members: vec![PathBuf::from("package1"), PathBuf::from("package2")], + metadata: Some(toml::Value::Table({ + let mut table = toml::value::Table::new(); + table.insert("key".to_string(), toml::Value::String("value".to_string())); + table + })), + }, + patch: None, + }; + + let serialized = toml::to_string(&original).unwrap(); + let deserialized: WorkspaceManifest = toml::from_str(&serialized).unwrap(); + + assert_eq!(original.workspace.members, deserialized.workspace.members); + assert_eq!(original.workspace.metadata, deserialized.workspace.metadata); + } }