diff --git a/mlf-cli/src/check.rs b/mlf-cli/src/check.rs index fc9852b..99b26c9 100644 --- a/mlf-cli/src/check.rs +++ b/mlf-cli/src/check.rs @@ -1,4 +1,4 @@ -use crate::config::{ConfigError, MlfConfig, find_project_root, get_mlf_cache_dir}; +use crate::config::{ConfigError, MlfConfig, PackageConfig, find_project_root, get_mlf_cache_dir}; use crate::workspace_ext::workspace_with_std_and_cache; use miette::Diagnostic; use mlf_diagnostics::{ParseDiagnostic, ValidationDiagnostic}; @@ -46,6 +46,37 @@ pub enum CheckError { #[error("Failed to load config: {0}")] #[diagnostic(code(mlf::check::config_error))] ConfigError(#[from] ConfigError), + + #[error("Lexicon `{namespace}` (in {file}) is outside package scope `{package_name}.*`")] + #[diagnostic( + code(mlf::check::scope_violation), + help( + "Move the file under `{package_name}.*` or change `[package].name` in mlf.toml so it covers this namespace." + ) + )] + ScopeViolation { + file: String, + namespace: String, + package_name: String, + }, +} + +fn enforce_scope( + namespace: &str, + file: &str, + package: Option<&PackageConfig>, +) -> Result<(), CheckError> { + let Some(pkg) = package else { + return Ok(()); + }; + if pkg.namespace_is_in_scope(namespace) { + return Ok(()); + } + Err(CheckError::ScopeViolation { + file: file.to_string(), + namespace: namespace.to_string(), + package_name: pkg.name.clone(), + }) } pub fn run_check( @@ -57,8 +88,11 @@ pub fn run_check( source: e, })?; - // Determine root directory and input paths - let (root_dir, file_paths) = if input_paths.is_empty() { + // Determine root directory, input paths, and the package config (if any). + // The package config drives the scope contract — when present, every + // lexicon we load must declare a namespace descended from + // `[package].name`. + let (root_dir, file_paths, package) = if input_paths.is_empty() { // No input provided: must use mlf.toml match find_project_root(¤t_dir) { Ok(project_root) => { @@ -71,9 +105,8 @@ pub fn run_check( config.source.directory ); - // Collect all .mlf files from source directory let files = collect_mlf_files(&source_dir)?; - (root, files) + (root, files, Some(config.package)) } Err(ConfigError::NotFound) => { return Err(CheckError::ValidationErrors { @@ -83,24 +116,29 @@ pub fn run_check( Err(e) => return Err(CheckError::ConfigError(e)), } } else { - // Input provided: determine root - let root = if let Some(explicit) = explicit_root { - // --root flag takes precedence - explicit + // Input provided: determine root, and pick up the package config + // if one exists, but don't require it — users may be linting + // standalone files outside a project. + let (root, package) = if let Some(explicit) = explicit_root { + let pkg = find_project_root(¤t_dir) + .ok() + .and_then(|r| MlfConfig::load(&r.join("mlf.toml")).ok()) + .map(|c| c.package); + (explicit, pkg) } else if let Ok(project_root) = find_project_root(¤t_dir) { - // Try to use mlf.toml source directory let config_path = project_root.join("mlf.toml"); if let Ok(config) = MlfConfig::load(&config_path) { - project_root.join(&config.source.directory) + ( + project_root.join(&config.source.directory), + Some(config.package), + ) } else { - current_dir.clone() + (current_dir.clone(), None) } } else { - // Fall back to current directory - current_dir.clone() + (current_dir.clone(), None) }; - // Collect files from input paths let mut files = Vec::new(); for input_path in input_paths { let path = if input_path.is_absolute() { @@ -120,7 +158,7 @@ pub fn run_check( }); } } - (root, files) + (root, files, package) }; // Try to load cached lexicons from .mlf directory @@ -163,6 +201,12 @@ pub fn run_check( let namespace = extract_namespace(&file_path, &root_dir)?; + if let Err(e) = enforce_scope(&namespace, &filename, package.as_ref()) { + eprintln!("{:?}", miette::Report::new(e)); + had_parse_errors = true; + continue; + } + if let Err(e) = workspace.add_module(namespace.clone(), lexicon.clone()) { let diagnostic = ValidationDiagnostic::new(filename.clone(), source.clone(), namespace.clone(), e); diff --git a/mlf-cli/src/config.rs b/mlf-cli/src/config.rs index f12d79e..52e54f3 100644 --- a/mlf-cli/src/config.rs +++ b/mlf-cli/src/config.rs @@ -13,10 +13,21 @@ pub enum ConfigError { #[error("No mlf.toml found in current directory or parent directories")] NotFound, + + #[error( + "Lexicon `{namespace}` (in {file}) is outside this package's scope `{package_name}.*`. Move the file or update `[package].name` in mlf.toml." + )] + ScopeViolation { + file: String, + namespace: String, + package_name: String, + }, } -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Serialize, Deserialize, Clone)] pub struct MlfConfig { + pub package: PackageConfig, + #[serde(default)] pub source: SourceConfig, @@ -27,7 +38,40 @@ pub struct MlfConfig { pub dependencies: DependenciesConfig, } -#[derive(Debug, Serialize, Deserialize)] +/// Identity of this MLF package. +/// +/// `name` is the NSID prefix every lexicon in the package must sit under — +/// e.g. `name = "com.example.forum"` means every `.mlf` file in the +/// workspace must declare a namespace that equals `com.example.forum` or +/// descends from it. The contract is checked when lexicons are loaded; a +/// mis-scoped file is a hard error, not a warning. +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct PackageConfig { + pub name: String, +} + +impl PackageConfig { + /// Is `namespace` `name` itself or a descendant of it? + pub fn namespace_is_in_scope(&self, namespace: &str) -> bool { + if namespace == self.name { + return true; + } + let prefix = format!("{}.", self.name); + namespace.starts_with(&prefix) + } + + /// Produce a [`ConfigError::ScopeViolation`] for the given out-of-scope + /// namespace + source file path. + pub fn scope_violation(&self, file: impl Into, namespace: &str) -> ConfigError { + ConfigError::ScopeViolation { + file: file.into(), + namespace: namespace.to_string(), + package_name: self.name.clone(), + } + } +} + +#[derive(Debug, Serialize, Deserialize, Clone)] pub struct SourceConfig { #[serde( default = "default_source_directory", @@ -52,13 +96,13 @@ fn is_default_source_directory(s: &str) -> bool { s == default_source_directory() } -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Serialize, Deserialize, Clone)] pub struct OutputConfig { pub r#type: String, pub directory: String, } -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Serialize, Deserialize, Clone)] pub struct DependenciesConfig { #[serde(default)] pub dependencies: Vec, @@ -102,17 +146,20 @@ impl Default for DependenciesConfig { } } -impl Default for MlfConfig { - fn default() -> Self { +impl MlfConfig { + /// Create a fresh config for a package. The package name is required; + /// all other fields default. + pub fn new(package_name: impl Into) -> Self { Self { + package: PackageConfig { + name: package_name.into(), + }, source: SourceConfig::default(), output: vec![], dependencies: DependenciesConfig::default(), } } -} -impl MlfConfig { pub fn load(path: &Path) -> Result { let content = std::fs::read_to_string(path)?; let config: MlfConfig = toml::from_str(&content)?; @@ -120,14 +167,15 @@ impl MlfConfig { } pub fn save(&self, path: &Path) -> Result<(), ConfigError> { - let content = toml::to_string_pretty(self) - .map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e))?; + let content = + toml::to_string_pretty(self).map_err(|e| std::io::Error::other(e.to_string()))?; std::fs::write(path, content)?; Ok(()) } - pub fn create_default(path: &Path) -> Result { - let config = MlfConfig::default(); + /// Create a new mlf.toml at `path` for a package with the given name. + pub fn create(path: &Path, package_name: impl Into) -> Result { + let config = MlfConfig::new(package_name); config.save(path)?; Ok(config) } @@ -219,12 +267,12 @@ impl LockFile { } let content = std::fs::read_to_string(path)?; - toml::from_str(&content).map_err(|e| ConfigError::ParseError(e)) + toml::from_str(&content).map_err(ConfigError::ParseError) } pub fn save(&self, path: &Path) -> Result<(), ConfigError> { - let content = toml::to_string_pretty(self) - .map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e))?; + let content = + toml::to_string_pretty(self).map_err(|e| std::io::Error::other(e.to_string()))?; std::fs::write(path, content)?; Ok(()) } @@ -253,15 +301,59 @@ mod tests { use super::*; #[test] - fn test_default_config() { - let config = MlfConfig::default(); + fn new_config_carries_package_name() { + let config = MlfConfig::new("com.example.forum"); + assert_eq!(config.package.name, "com.example.forum"); assert_eq!(config.source.directory, "./lexicons"); assert!(config.output.is_empty()); assert!(config.dependencies.dependencies.is_empty()); } #[test] - fn test_lockfile_basic() { + fn load_requires_package_section() { + // TOML missing `[package]` → parse error. + let toml_str = r#" + [source] + directory = "./lexicons" + "#; + let err = toml::from_str::(toml_str).unwrap_err(); + assert!(err.to_string().to_lowercase().contains("package")); + } + + #[test] + fn package_scope_accepts_exact_and_descendants() { + let pkg = PackageConfig { + name: "com.example.forum".into(), + }; + assert!(pkg.namespace_is_in_scope("com.example.forum")); + assert!(pkg.namespace_is_in_scope("com.example.forum.thread")); + assert!(pkg.namespace_is_in_scope("com.example.forum.thread.reply")); + } + + #[test] + fn package_scope_rejects_siblings_and_unrelated() { + let pkg = PackageConfig { + name: "com.example.forum".into(), + }; + assert!(!pkg.namespace_is_in_scope("com.example.forums")); // not a dot boundary + assert!(!pkg.namespace_is_in_scope("com.other.forum")); + assert!(!pkg.namespace_is_in_scope("com.example")); + } + + #[test] + fn scope_violation_names_file_and_prefix() { + let pkg = PackageConfig { + name: "com.foo".into(), + }; + let err = pkg.scope_violation("lexicons/com/bar/baz.mlf", "com.bar.baz"); + let msg = err.to_string(); + assert!(msg.contains("com.bar.baz")); + assert!(msg.contains("com.foo")); + assert!(msg.contains("lexicons/com/bar/baz.mlf")); + } + + #[test] + fn lockfile_basic() { let mut lockfile = LockFile::new(); assert_eq!(lockfile.version, 1); assert!(lockfile.lexicons.is_empty()); diff --git a/mlf-cli/src/fetch.rs b/mlf-cli/src/fetch.rs index c06a30c..34b68e4 100644 --- a/mlf-cli/src/fetch.rs +++ b/mlf-cli/src/fetch.rs @@ -103,23 +103,11 @@ fn ensure_project_root(current_dir: &std::path::Path) -> Result Ok(root), Err(ConfigError::NotFound) => { - // Ask user if they want to create mlf.toml eprintln!("No mlf.toml found in current or parent directories."); - eprintln!("Would you like to create one in the current directory? (y/n)"); - - let mut input = String::new(); - std::io::stdin() - .read_line(&mut input) - .map_err(FetchError::InitFailed)?; - - if input.trim().to_lowercase() == "y" { - let config_path = current_dir.join("mlf.toml"); - MlfConfig::create_default(&config_path).map_err(FetchError::NoProjectRoot)?; - println!("Created mlf.toml in {}", current_dir.display()); - Ok(current_dir.to_path_buf()) - } else { - Err(FetchError::NoProjectRoot(ConfigError::NotFound)) - } + eprintln!( + "Run `mlf init` first to set up a project (it will prompt for the package NSID prefix)." + ); + Err(FetchError::NoProjectRoot(ConfigError::NotFound)) } Err(e) => Err(FetchError::NoProjectRoot(e)), } diff --git a/mlf-cli/src/init.rs b/mlf-cli/src/init.rs index 235119c..6d153e6 100644 --- a/mlf-cli/src/init.rs +++ b/mlf-cli/src/init.rs @@ -5,7 +5,6 @@ pub fn run_init(skip_prompts: bool) -> Result<(), std::io::Error> { let current_dir = std::env::current_dir()?; let config_path = current_dir.join("mlf.toml"); - // Check if mlf.toml already exists if config_path.exists() { eprintln!("mlf.toml already exists in current directory"); eprintln!("Remove it first if you want to reinitialize"); @@ -33,15 +32,21 @@ pub fn run_init(skip_prompts: bool) -> Result<(), std::io::Error> { } } - // Create default mlf.toml - let config = MlfConfig::default(); - config - .save(&config_path) - .map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e))?; + // Collect the package NSID prefix. + let package_name = if skip_prompts { + // --yes was passed; derive a best-effort placeholder the user can + // edit. Tying this to the directory name is a better starting + // point than a magic constant. + default_package_name(¤t_dir) + } else { + prompt_package_name()? + }; - println!("✓ Created mlf.toml"); + let config = MlfConfig::create(&config_path, package_name) + .map_err(|e| std::io::Error::other(e.to_string()))?; + + println!("✓ Created mlf.toml (package `{}`)", config.package.name); - // Initialize .mlf directory init_mlf_cache(¤t_dir)?; println!("✓ Initialized .mlf/ directory"); @@ -56,3 +61,34 @@ pub fn run_init(skip_prompts: bool) -> Result<(), std::io::Error> { Ok(()) } + +fn prompt_package_name() -> Result { + loop { + print!("Package NSID prefix (e.g. com.example.forum): "); + std::io::stdout().flush()?; + let mut input = String::new(); + std::io::stdin().read_line(&mut input)?; + let trimmed = input.trim(); + if trimmed.is_empty() { + eprintln!("A package name is required (at least two dot-separated segments)."); + continue; + } + if !trimmed.contains('.') { + eprintln!("Expected a dot-separated NSID prefix (e.g. com.example.forum)."); + continue; + } + return Ok(trimmed.to_string()); + } +} + +fn default_package_name(dir: &std::path::Path) -> String { + let dir_name = dir + .file_name() + .and_then(|s| s.to_str()) + .unwrap_or("package"); + if dir_name.contains('.') { + dir_name.to_string() + } else { + format!("example.{dir_name}") + } +} diff --git a/website/content/docs/cli/02-configuration.md b/website/content/docs/cli/02-configuration.md index 8204a15..4ef7e52 100644 --- a/website/content/docs/cli/02-configuration.md +++ b/website/content/docs/cli/02-configuration.md @@ -11,6 +11,9 @@ MLF projects can be configured with an `mlf.toml` file that specifies source dir Create an `mlf.toml` file in your project root: ```toml +[package] +name = "com.example.forum" + [source] directory = "./lexicons" @@ -28,6 +31,19 @@ dependencies = ["stream.place", "app.bsky"] ## Configuration Sections +### Package Identity + +The `[package]` section declares this project's NSID prefix. It's **required**. + +```toml +[package] +name = "com.example.forum" +``` + +Every lexicon in your workspace must declare a namespace that equals `name` or descends from it — `com.example.forum`, `com.example.forum.thread`, `com.example.forum.thread.reply` are all in-scope; `com.example.forums` (note the trailing `s`) and `com.other.thing` are not. A mis-scoped file is a hard error at load time, not a warning, so typos can't silently leak into generated output or (eventually) a `mlf publish` run. + +This contract is also what lets the publisher know which DNS authorities it will need to touch: every NSID under `name` rolls up to one or more `_lexicon.` TXT records under your DNS zone. + ### Source Directory The `[source]` section specifies where your MLF files are located: @@ -219,6 +235,9 @@ Now `./src/schemas/com/example/thread.mlf` → namespace `com.example.thread` Here's a complete `mlf.toml` for a TypeScript project using ATProto lexicons: ```toml +[package] +name = "com.example.forum" + [source] directory = "./lexicons" diff --git a/website/content/docs/cli/03-init.md b/website/content/docs/cli/03-init.md index 6f5636a..1668046 100644 --- a/website/content/docs/cli/03-init.md +++ b/website/content/docs/cli/03-init.md @@ -9,10 +9,10 @@ The `mlf init` command creates a new MLF project with default configuration and ## Usage ```bash -# Interactive initialization (prompts for confirmation) +# Interactive initialization (prompts for confirmation and package NSID) mlf init -# Skip prompts +# Skip prompts (package NSID defaults to the containing directory name) mlf init --yes ``` @@ -23,8 +23,11 @@ mlf init --yes Running `mlf init` creates: -1. **mlf.toml** - Project configuration file with defaults: +1. **mlf.toml** - Project configuration file. Interactive mode prompts for the package NSID prefix; `--yes` derives a placeholder from the containing directory that you can edit: ```toml + [package] + name = "com.example.forum" + [source] directory = "./lexicons" @@ -32,6 +35,8 @@ Running `mlf init` creates: dependencies = [] ``` + `[package].name` is the NSID prefix every `.mlf` file in this project must sit under. See [Configuration → Package Identity](../02-configuration/#package-identity) for the scope contract. + 2. **.mlf/** - Cache directory structure: ``` .mlf/ @@ -57,7 +62,8 @@ This will create: - .mlf/ (cache directory for fetched lexicons) Continue? (y/n): y -✓ Created mlf.toml +Package NSID prefix (e.g. com.example.forum): com.example.forum +✓ Created mlf.toml (package `com.example.forum`) ✓ Initialized .mlf/ directory Project initialized successfully! @@ -75,7 +81,7 @@ Use `--yes` to skip prompts (useful for scripts and CI/CD): ```bash $ mlf init --yes -✓ Created mlf.toml +✓ Created mlf.toml (package `example.my-project`) ✓ Initialized .mlf/ directory Project initialized successfully! @@ -153,6 +159,9 @@ If you prefer manual setup, you can create these files yourself: **mlf.toml:** ```toml +[package] +name = "com.example.forum" + [source] directory = "./lexicons"