diff --git a/Cargo.toml b/Cargo.toml index bc30f52..be2f4b8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,3 +18,14 @@ exclude = ["crates/draw-py"] lto = true strip = true codegen-units = 1 + +# Surgical clippy opt-ins. We don't enable clippy::pedantic wholesale — the +# f32/f64 cast and must_use_candidate lints drown real signal. These are the +# specific lints that caught bugs or unclear intent during review. +[workspace.lints.clippy] +manual_let_else = "warn" +missing_errors_doc = "warn" +missing_panics_doc = "warn" +redundant_closure_for_method_calls = "warn" +uninlined_format_args = "warn" +semicolon_if_nothing_returned = "warn" diff --git a/crates/draw-app/Cargo.toml b/crates/draw-app/Cargo.toml index 2012303..9d6077b 100644 --- a/crates/draw-app/Cargo.toml +++ b/crates/draw-app/Cargo.toml @@ -27,3 +27,6 @@ objc2-foundation = { version = "0.3", features = ["NSString"], default-features objc2 = "0.6" tokio = { version = "1", features = ["rt-multi-thread", "macros", "net"] } wry = "0.55" + +[lints] +workspace = true diff --git a/crates/draw-app/src/lib.rs b/crates/draw-app/src/lib.rs index d6139d5..268e083 100644 --- a/crates/draw-app/src/lib.rs +++ b/crates/draw-app/src/lib.rs @@ -46,6 +46,19 @@ fn setup_macos_menu() { #[cfg(not(target_os = "macos"))] fn setup_macos_menu() {} +/// Launch the desktop app wrapping the embedded webapp in a native webview. +/// +/// If `open_id` is set, the window opens that drawing on launch. +/// +/// # Errors +/// Returns an error if the webview event loop or window cannot be created, +/// the embedded webapp fails to report back a bound port, or the webview +/// process terminates abnormally. +/// +/// # Panics +/// Panics if the background tokio runtime for the embedded webapp cannot be +/// built or its listener cannot bind a port. These are treated as fatal +/// because the desktop app has no UI to report them. pub fn run_app(open_id: Option) -> anyhow::Result<()> { // Start the axum webapp on a random available port in a background thread let (port_tx, port_rx) = std::sync::mpsc::channel::(); diff --git a/crates/draw-cli/Cargo.toml b/crates/draw-cli/Cargo.toml index 47423e9..98180ed 100644 --- a/crates/draw-cli/Cargo.toml +++ b/crates/draw-cli/Cargo.toml @@ -30,3 +30,6 @@ dkdc-draw-core = { version = "0.2.1", path = "../draw-core" } dkdc-draw-app = { version = "0.2.1", path = "../draw-app", optional = true } dkdc-draw-webapp = { version = "0.2.1", path = "../draw-webapp", optional = true } clap = { version = "4.5", features = ["derive"] } + +[lints] +workspace = true diff --git a/crates/draw-cli/src/cli.rs b/crates/draw-cli/src/cli.rs index 2fa7b3c..3de1eb3 100644 --- a/crates/draw-cli/src/cli.rs +++ b/crates/draw-cli/src/cli.rs @@ -59,6 +59,15 @@ pub enum Command { }, } +/// Parse `argv` as the draw CLI and dispatch the matching subcommand. +/// +/// Clap handles `--help` / `--version` / argument errors itself by calling +/// `process::exit`, so those paths never return here. +/// +/// # Errors +/// Returns an error if a subcommand fails: I/O errors loading or exporting a +/// drawing, a requested drawing id not found, or the embedded webapp/app +/// failing to launch. pub fn run_cli(argv: impl IntoIterator>) -> Result<()> { let argv: Vec = argv.into_iter().map(Into::into).collect(); diff --git a/crates/draw-core/Cargo.toml b/crates/draw-core/Cargo.toml index 4e4d840..3be7b2e 100644 --- a/crates/draw-core/Cargo.toml +++ b/crates/draw-core/Cargo.toml @@ -23,3 +23,6 @@ uuid = { version = "1", features = ["v4"] } [dev-dependencies] tempfile = "3" + +[lints] +workspace = true diff --git a/crates/draw-core/src/element.rs b/crates/draw-core/src/element.rs index 1f1487e..2736af0 100644 --- a/crates/draw-core/src/element.rs +++ b/crates/draw-core/src/element.rs @@ -68,7 +68,7 @@ impl Element { } pub fn set_position(&mut self, x: f64, y: f64) { - with_element!(self, e => { e.x = x; e.y = y; }) + with_element!(self, e => { e.x = x; e.y = y; }); } pub fn opacity(&self) -> f64 { diff --git a/crates/draw-core/src/export_png.rs b/crates/draw-core/src/export_png.rs index a2353d3..0f47e36 100644 --- a/crates/draw-core/src/export_png.rs +++ b/crates/draw-core/src/export_png.rs @@ -5,11 +5,21 @@ use crate::export_svg::export_svg; const DEFAULT_SCALE: f32 = 2.0; /// Export a document as PNG bytes at the default 2x scale. +/// +/// See [`export_png_with_scale`] for error conditions. +/// +/// # Errors +/// Returns an error under the same conditions as [`export_png_with_scale`]. pub fn export_png(doc: &Document) -> anyhow::Result> { export_png_with_scale(doc, DEFAULT_SCALE) } /// Export a document as PNG bytes at a given scale factor. +/// +/// # Errors +/// Returns an error if the intermediate SVG cannot be parsed, the scaled +/// dimensions round to zero, allocating the pixmap fails, or PNG encoding +/// fails. pub fn export_png_with_scale(doc: &Document, scale: f32) -> anyhow::Result> { let svg_string = export_svg(doc); diff --git a/crates/draw-core/src/export_svg.rs b/crates/draw-core/src/export_svg.rs index 4fd8d62..8885d4a 100644 --- a/crates/draw-core/src/export_svg.rs +++ b/crates/draw-core/src/export_svg.rs @@ -22,29 +22,27 @@ pub fn export_svg(doc: &Document) -> String { let w = bounds.width + padding * 2.0; let h = bounds.height + padding * 2.0; - let mut svg = format!( - r#""# - ); - svg.push('\n'); - - // Collect defs (clipPaths for hachure fills) let mut defs = String::new(); + let mut body = String::new(); let mut clip_id: usize = 0; for element in &doc.elements { let (element_svg, element_defs) = render_element(element, &mut clip_id); defs.push_str(&element_defs); - svg.push_str(&element_svg); - svg.push('\n'); + body.push_str(&element_svg); + body.push('\n'); } - // Insert defs block before elements if needed + let mut svg = format!( + r#""# + ); + svg.push('\n'); if !defs.is_empty() { - let defs_block = format!(" \n{defs} \n"); - let insert_pos = svg.find('\n').unwrap() + 1; - svg.insert_str(insert_pos, &defs_block); + svg.push_str(" \n"); + svg.push_str(&defs); + svg.push_str(" \n"); } - + svg.push_str(&body); svg.push_str(""); svg } @@ -352,10 +350,13 @@ fn render_hachure_group(clip_id: &str, bounds: &Bounds, fill: &FillStyle, opacit )); } + // TODO: opacity="…" and style="opacity:…" both set opacity on the same + // ; the inline `style` wins in the CSS cascade and the attribute is + // effectively dead. Kept as-is to preserve output byte-equivalence with + // prior behavior; address in a follow-up. format!( - r#" -{lines} "#, - opacity + r#" +{lines} "# ) } @@ -371,7 +372,7 @@ fn fill_attr(color: &str, style: &FillType) -> String { fn stroke_attrs(color: &str, width: f64, dash: &[f64]) -> String { let mut s = format!(r#"stroke="{color}" stroke-width="{width}""#); if !dash.is_empty() { - let dash_str: Vec = dash.iter().map(|d| d.to_string()).collect(); + let dash_str: Vec = dash.iter().map(f64::to_string).collect(); s.push_str(&format!(r#" stroke-dasharray="{}""#, dash_str.join(","))); } s diff --git a/crates/draw-core/src/geometry.rs b/crates/draw-core/src/geometry.rs index e5189c8..b70f514 100644 --- a/crates/draw-core/src/geometry.rs +++ b/crates/draw-core/src/geometry.rs @@ -220,7 +220,7 @@ pub fn find_nearest_snap_point( let pts = connection_points(el); for cp in &pts { let dist = ((cp.x - wx).powi(2) + (cp.y - wy).powi(2)).sqrt(); - if dist < threshold && (best.is_none() || dist < best.as_ref().unwrap().3) { + if dist < threshold && best.as_ref().is_none_or(|b| dist < b.3) { best = Some((el.id().to_string(), cp.x, cp.y, dist)); } } diff --git a/crates/draw-core/src/render/draw.rs b/crates/draw-core/src/render/draw.rs index cd4bf23..6496834 100644 --- a/crates/draw-core/src/render/draw.rs +++ b/crates/draw-core/src/render/draw.rs @@ -271,9 +271,8 @@ impl super::Renderer { transform: &Transform, opacity: f32, ) { - let mut clip_mask = match Mask::new(pixmap.width(), pixmap.height()) { - Some(m) => m, - None => return, + let Some(mut clip_mask) = Mask::new(pixmap.width(), pixmap.height()) else { + return; }; clip_mask.fill_path(clip_path, FillRule::Winding, true, *transform); diff --git a/crates/draw-core/src/render/mod.rs b/crates/draw-core/src/render/mod.rs index 0c32273..f52af0e 100644 --- a/crates/draw-core/src/render/mod.rs +++ b/crates/draw-core/src/render/mod.rs @@ -111,6 +111,14 @@ impl Renderer { // ── Main render entry point ───────────────────────────────────── + /// Render the document to a pixmap. + /// + /// # Panics + /// Panics if `tiny_skia::Pixmap::new` fails to allocate. Width and height + /// are clamped to at least 1 via `.max(1)`, so the remaining failure modes + /// are allocator OOM or dimensions whose `width * height * 4` overflows + /// `i32::MAX` — conditions the caller is responsible for avoiding via + /// [`RenderConfig`] sizing. pub fn render( &self, doc: &Document, diff --git a/crates/draw-core/src/storage.rs b/crates/draw-core/src/storage.rs index bf1242f..64c01f5 100644 --- a/crates/draw-core/src/storage.rs +++ b/crates/draw-core/src/storage.rs @@ -5,6 +5,11 @@ use anyhow::{Context, Result}; use crate::document::Document; +/// Return the directory where drawings are stored, creating it if needed. +/// +/// # Errors +/// Returns an error if the platform config directory cannot be determined or +/// the directory cannot be created. pub fn storage_dir() -> Result { let dir = dirs::config_dir() .context("could not determine config directory")? @@ -14,6 +19,10 @@ pub fn storage_dir() -> Result { Ok(dir) } +/// Serialize a document to `path` as pretty-printed JSON. +/// +/// # Errors +/// Returns an error if JSON serialization fails or the file cannot be written. pub fn save(doc: &Document, path: &Path) -> Result<()> { let json = serde_json::to_string_pretty(doc)?; if let Some(parent) = path.parent() { @@ -23,6 +32,10 @@ pub fn save(doc: &Document, path: &Path) -> Result<()> { Ok(()) } +/// Save a document under [`storage_dir`] as `.draw.json`. +/// +/// # Errors +/// Returns an error if the storage directory cannot be resolved or writing fails. pub fn save_to_storage(doc: &Document) -> Result { let dir = storage_dir()?; let path = dir.join(format!("{}.draw.json", doc.id)); @@ -30,12 +43,20 @@ pub fn save_to_storage(doc: &Document) -> Result { Ok(path) } +/// Load a document from a `.draw.json` file. +/// +/// # Errors +/// Returns an error if the file cannot be read or the JSON is malformed. pub fn load(path: &Path) -> Result { let json = fs::read_to_string(path).context("could not read drawing file")?; let doc: Document = serde_json::from_str(&json).context("invalid drawing file")?; Ok(doc) } +/// List all drawings in [`storage_dir`] as `(name, path)` tuples, sorted by name. +/// +/// # Errors +/// Returns an error if the storage directory cannot be resolved or read. pub fn list_drawings() -> Result> { let dir = storage_dir()?; let mut drawings = vec![]; diff --git a/crates/draw-py/Cargo.toml b/crates/draw-py/Cargo.toml index 1ab694b..1318414 100644 --- a/crates/draw-py/Cargo.toml +++ b/crates/draw-py/Cargo.toml @@ -20,3 +20,13 @@ dkdc-draw = { version = "0.2.1", path = "../draw-cli", features = ["webapp", "ap dkdc-draw-core = { version = "0.2.1", path = "../draw-core" } pyo3 = { version = "0.28", features = ["extension-module", "abi3-py311"] } serde_json = "1" + +# draw-py is its own cargo workspace so it can't `workspace = true` onto the +# outer workspace lints — mirror the same surgical opt-ins here. +[lints.clippy] +manual_let_else = "warn" +missing_errors_doc = "warn" +missing_panics_doc = "warn" +redundant_closure_for_method_calls = "warn" +uninlined_format_args = "warn" +semicolon_if_nothing_returned = "warn" diff --git a/crates/draw-py/src/lib.rs b/crates/draw-py/src/lib.rs index 549c322..b5277ef 100644 --- a/crates/draw-py/src/lib.rs +++ b/crates/draw-py/src/lib.rs @@ -9,7 +9,7 @@ fn to_py_err(e: anyhow::Error) -> PyErr { #[pyfunction] fn run_cli(argv: Vec) -> PyResult<()> { - draw::run_cli(argv.iter().map(|s| s.as_str())).map_err(to_py_err) + draw::run_cli(argv.iter().map(String::as_str)).map_err(to_py_err) } #[pyfunction] diff --git a/crates/draw-wasm/Cargo.toml b/crates/draw-wasm/Cargo.toml index 8e6ca4b..cfb488a 100644 --- a/crates/draw-wasm/Cargo.toml +++ b/crates/draw-wasm/Cargo.toml @@ -26,3 +26,6 @@ wasm-bindgen = "0.2" uuid = { version = "1", features = ["js"] } [dev-dependencies] + +[lints] +workspace = true diff --git a/crates/draw-wasm/src/lib.rs b/crates/draw-wasm/src/lib.rs index 895d00b..91fc620 100644 --- a/crates/draw-wasm/src/lib.rs +++ b/crates/draw-wasm/src/lib.rs @@ -96,7 +96,7 @@ impl DrawEngine { /// Render the current state and return RGBA pixel data. pub fn render(&self) -> Vec { - let sel_refs: Vec<&str> = self.selected_ids.iter().map(|s| s.as_str()).collect(); + let sel_refs: Vec<&str> = self.selected_ids.iter().map(String::as_str).collect(); let mut pixmap = self.renderer.render( &self.document, &self.viewport, @@ -184,7 +184,7 @@ impl DrawEngine { draw_core::HandlePosition::SouthWest => "SouthWest", draw_core::HandlePosition::SouthEast => "SouthEast", }; - format!(r#"{{"id":"{}","handle":"{}"}}"#, id, handle_str) + format!(r#"{{"id":"{id}","handle":"{handle_str}"}}"#) } None => String::new(), } @@ -214,7 +214,7 @@ impl DrawEngine { pub fn screen_to_world(&self, sx: f64, sy: f64) -> String { let wx = (sx - self.viewport.scroll_x) / self.viewport.zoom; let wy = (sy - self.viewport.scroll_y) / self.viewport.zoom; - format!(r#"{{"x":{},"y":{}}}"#, wx, wy) + format!(r#"{{"x":{wx},"y":{wy}}}"#) } pub fn scroll_x(&self) -> f64 { @@ -366,22 +366,18 @@ impl DrawEngine { /// (stroke, fill, font, opacity, etc.) to merge into the element. pub fn update_element_style(&mut self, id: &str, style_json: &str) -> bool { // Snapshot "before" for undo - let before = self.document.get_element(id).cloned(); - let before = match before { - Some(b) => b, - None => return false, + let Some(before) = self.document.get_element(id).cloned() else { + return false; }; // Parse the style update as a generic JSON value - let updates: serde_json::Value = match serde_json::from_str(style_json) { - Ok(v) => v, - Err(_) => return false, + let Ok(updates) = serde_json::from_str::(style_json) else { + return false; }; // Serialize element, merge updates, deserialize back - let mut elem_val = match serde_json::to_value(&before) { - Ok(v) => v, - Err(_) => return false, + let Ok(mut elem_val) = serde_json::to_value(&before) else { + return false; }; if let (Some(obj), Some(upd)) = (elem_val.as_object_mut(), updates.as_object()) { @@ -512,7 +508,7 @@ impl DrawEngine { /// Get all element IDs as a JSON array. pub fn get_all_element_ids(&self) -> String { - let ids: Vec<&str> = self.document.elements.iter().map(|el| el.id()).collect(); + let ids: Vec<&str> = self.document.elements.iter().map(Element::id).collect(); serde_json::to_string(&ids).unwrap_or_else(|_| "[]".to_string()) } @@ -523,7 +519,7 @@ impl DrawEngine { .elements .iter() .filter(|el| el.group_id() == Some(group_id)) - .map(|el| el.id()) + .map(Element::id) .collect(); serde_json::to_string(&ids).unwrap_or_else(|_| "[]".to_string()) } @@ -626,7 +622,7 @@ impl DrawEngine { exclude_id, ) { Some((element_id, x, y)) => { - format!(r#"{{"element_id":"{}","x":{},"y":{}}}"#, element_id, x, y) + format!(r#"{{"element_id":"{element_id}","x":{x},"y":{y}}}"#) } None => String::new(), } diff --git a/crates/draw-webapp/Cargo.toml b/crates/draw-webapp/Cargo.toml index 77579a7..a930dad 100644 --- a/crates/draw-webapp/Cargo.toml +++ b/crates/draw-webapp/Cargo.toml @@ -23,3 +23,6 @@ open = "5" serde = { version = "1", features = ["derive"] } serde_json = "1" tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "signal"] } + +[lints] +workspace = true diff --git a/crates/draw-webapp/build.rs b/crates/draw-webapp/build.rs index 1ad404e..1191fc2 100644 --- a/crates/draw-webapp/build.rs +++ b/crates/draw-webapp/build.rs @@ -44,9 +44,8 @@ fn main() { Ok(s) if s.success() => {} Ok(s) => { panic!( - "wasm-pack build failed with exit code: {}. \ - Make sure wasm-pack is installed: cargo install wasm-pack", - s + "wasm-pack build failed with exit code: {s}. \ + Make sure wasm-pack is installed: cargo install wasm-pack" ); } Err(e) if e.kind() == std::io::ErrorKind::NotFound => { diff --git a/crates/draw-webapp/src/lib.rs b/crates/draw-webapp/src/lib.rs index ac97a45..696c4d7 100644 --- a/crates/draw-webapp/src/lib.rs +++ b/crates/draw-webapp/src/lib.rs @@ -103,6 +103,13 @@ fn static_response( (headers, content) } +/// Run the axum webapp, serving the embedded frontend on [`PORT`]. +/// +/// If `open_id` is set, the browser opens that drawing on launch. +/// +/// # Errors +/// Returns an error if the tokio runtime cannot be built, the port is in use, +/// or the server fails while running. pub fn run_webapp(open_id: Option) -> anyhow::Result<()> { let rt = tokio::runtime::Runtime::new()?; rt.block_on(async {