Repository: railwayapp/nixpacks (rust)

Issue:
`nixpack plan` generates an invalid toml file for python projects containing a `.tool-versions` file.
### Is there an existing issue for this?

- [X] I have searched the existing issues

### Describe the bug

When running `nixpack plan -f toml` in a directory containing a Poetry project and a .tool-versions file nixpacks outputs a line before it outputs the .toml-file:

```
Using poetry version from .tool-versions: 1.8.5
```

This breaks the deployment code in Coolify, because Coolify will read the output and try to parse it as toml.
I expect the same problem to occur with the json output format.

### To reproduce

1. Create a Poetry project or simulate this with `touch pyproject.toml poetry.lock`
2. `echo "poetry 1.8.5" > .tool-versions`
3. `nixpacks plan -f toml  .`

### Expected behavior

I would expect nixpacks to output just the toml file of the build plan.

### Environment

On the host:
```
$ docker -v
Docker version 27.0.3, build 7d4bcd8
```

In the container:
```
cat /etc/os-release
NAME="Alpine Linux"
ID=alpine
VERSION_ID=3.20.3
PRETTY_NAME="Alpine Linux v3.20"
HOME_URL="https://alpinelinux.org/"
BUG_REPORT_URL="https://gitlab.alpinelinux.org/alpine/aports/-/issues"
```

``` 
$ nixpacks --version
nixpacks 1.29.0
```

Pull request:
Fix #1241: nixpack plan generates an invalid toml file ...
See #1241 nixpack plan generates an invalid toml file for python projects containing a .tool-versions file.

I assume that outputting those lines before the toml or json file is not intended. If it is then please ignore this commit.

Another option might be to print these messages to stderr instead?

Patch:
diff --git a/examples/deno-jsonc/deno.jsonc b/examples/deno-jsonc/deno.jsonc
new file mode 100644
index 0000000..9064591
--- /dev/null
+++ b/examples/deno-jsonc/deno.jsonc
@@ -0,0 +1,15 @@
+// THIS FILE HAS ALL KINDS OF COMMENTS POSSIBLE TO TEST JSONC
+{
+  //some random comment
+  "tasks": {
+    // random afterline comment
+    /* :) */ "dev": "deno run --watch main.ts",
+    "start": "deno start main.ts"
+  } /*
+    This 
+    is some multiline comment
+    */,
+  "imports": {
+    "@std/assert": "jsr:@std/assert@1"
+  }
+}
diff --git a/examples/deno-jsonc/deno.lock b/examples/deno-jsonc/deno.lock
new file mode 100644
index 0000000..3513c0f
--- /dev/null
+++ b/examples/deno-jsonc/deno.lock
@@ -0,0 +1,18 @@
+{
+  "version": "4",
+  "specifiers": {
+    "jsr:@std/assert@1": "1.0.8",
+    "jsr:@std/internal@^1.0.5": "1.0.5"
+  },
+  "jsr": {
+    "@std/assert@1.0.8": {
+      "integrity": "ebe0bd7eb488ee39686f77003992f389a06c3da1bbd8022184804852b2fa641b",
+      "dependencies": [
+        "jsr:@std/internal"
+      ]
+    },
+    "@std/internal@1.0.5": {
+      "integrity": "54a546004f769c1ac9e025abd15a76b6671ddc9687e2313b67376125650dc7ba"
+    }
+  }
+}
diff --git a/examples/deno-jsonc/main.ts b/examples/deno-jsonc/main.ts
new file mode 100644
index 0000000..bdf52b9
--- /dev/null
+++ b/examples/deno-jsonc/main.ts
@@ -0,0 +1,7 @@
+export function add(a: number, b: number): number {
+  return a + b;
+}
+// Learn more at https://docs.deno.com/runtime/manual/examples/module_metadata#concepts
+if (import.meta.main) {
+  console.log("Add 2 + 3 =", add(2, 3));
+}
\ No newline at end of file
diff --git a/examples/node-node-version/.node-version b/examples/node-node-version/.node-version
new file mode 100644
index 0000000..dc0bb0f
--- /dev/null
+++ b/examples/node-node-version/.node-version
@@ -0,0 +1,1 @@
+v22.12.0
diff --git a/examples/node-node-version/index.js b/examples/node-node-version/index.js
new file mode 100644
index 0000000..140fc15
--- /dev/null
+++ b/examples/node-node-version/index.js
@@ -0,0 +1,2 @@
+console.log("Hello from Node");
+console.log(`Node version: ${process.version}`);
diff --git a/examples/node-node-version/package-lock.json b/examples/node-node-version/package-lock.json
new file mode 100644
index 0000000..d18e0e0
--- /dev/null
+++ b/examples/node-node-version/package-lock.json
@@ -0,0 +1,12 @@
+{
+  "name": "node",
+  "version": "1.0.0",
+  "lockfileVersion": 2,
+  "requires": true,
+  "packages": {
+    "": {
+      "name": "node",
+      "version": "1.0.0"
+    }
+  }
+}
diff --git a/examples/node-node-version/package.json b/examples/node-node-version/package.json
new file mode 100644
index 0000000..2f12d8f
--- /dev/null
+++ b/examples/node-node-version/package.json
@@ -0,0 +1,8 @@
+{
+  "name": "node",
+  "version": "1.0.0",
+  "main": "index.js",
+  "scripts": {
+    "start": "node index.js"
+  }
+}
diff --git a/src/nixpacks/app.rs b/src/nixpacks/app.rs
index d94e841..7aea9a1 100644
--- a/src/nixpacks/app.rs
+++ b/src/nixpacks/app.rs
@@ -190,6 +190,47 @@ impl App {
         Ok(toml_file)
     }
 
+    /// Parse jsonc files as json by ignoring all kinds of comments
+    pub fn read_jsonc<T>(&self, name: &str) -> Result<T>
+    where
+        T: DeserializeOwned,
+    {
+        let mut cleaned_jsonc = String::new();
+        let contents = self.read_file(name)?;
+        let mut chars = contents.chars().peekable();
+        while let Some(current_char) = chars.next() {
+            match current_char {
+                '/' if chars.peek() == Some(&'/') => {
+                    while let Some(&next_char) = chars.peek() {
+                        chars.next();
+                        if next_char == '\n' {
+                            break;
+                        }
+                    }
+                }
+                '/' if chars.peek() == Some(&'*') => {
+                    chars.next();
+                    loop {
+                        match chars.next() {
+                            Some('*') if chars.peek() == Some(&'/') => {
+                                chars.next();
+                                break;
+                            }
+                            None => break,
+                            _ => continue,
+                        }
+                    }
+                }
+                _ => cleaned_jsonc.push(current_char),
+            }
+        }
+        let value: T = serde_json::from_str(cleaned_jsonc.as_str()).with_context(|| {
+            let relative_path = self.strip_source_path(Path::new(name)).unwrap();
+            format!("Error reading {} as JSONC", relative_path.to_str().unwrap())
+        })?;
+        Ok(value)
+    }
+
     /// Try to yaml-parse a file.
     pub fn read_yaml<T>(&self, name: &str) -> Result<T>
     where
@@ -281,6 +322,14 @@ mod tests {
         Ok(())
     }
 
+    #[test]
+    fn test_read_jsonc_file() -> Result<()> {
+        let app = App::new("./examples/deno-jsonc")?;
+        let value: Map<String, Value> = app.read_jsonc("deno.jsonc")?;
+        assert!(value.get("tasks").is_some());
+        Ok(())
+    }
+
     #[test]
     fn test_read_toml_file() -> Result<()> {
         let app = App::new("./examples/rust-rocket")?;
diff --git a/src/providers/deno.rs b/src/providers/deno.rs
index a5289ec..3deef7b 100644
--- a/src/providers/deno.rs
+++ b/src/providers/deno.rs
@@ -82,7 +82,7 @@ impl DenoProvider {
         if app.includes_file("deno.json") || app.includes_file("deno.jsonc") {
             let deno_json: DenoJson = app
                 .read_json("deno.json")
-                .or_else(|_| app.read_json("deno.jsonc"))?;
+                .or_else(|_| app.read_jsonc("deno.jsonc"))?;
 
             if let Some(tasks) = deno_json.tasks {
                 if let Some(start) = tasks.start {
diff --git a/src/providers/node/mod.rs b/src/providers/node/mod.rs
index e4e72e3..49e727f 100644
--- a/src/providers/node/mod.rs
+++ b/src/providers/node/mod.rs
@@ -340,7 +340,18 @@ impl NodeProvider {
             None
         };
 
-        let node_version = env_node_version.or(pkg_node_version).or(nvmrc_node_version);
+        let dot_node_version = if app.includes_file(".node-version") {
+            let node_version_file = app.read_file(".node-version")?;
+            // Using simple string transform since .node-version don't currently have a convention around the use of lts/* implemented in parse_nvmrc method
+            Some(node_version_file.trim().replace('v', ""))
+        } else {
+            None
+        };
+
+        let node_version = env_node_version
+            .or(pkg_node_version)
+            .or(nvmrc_node_version)
+            .or(dot_node_version);
 
         let node_version = match node_version {
             Some(node_version) => node_version,
@@ -362,7 +373,7 @@ impl NodeProvider {
             pkg_manager = "pnpm";
         } else if app.includes_file("yarn.lock") {
             pkg_manager = "yarn";
-        } else if app.includes_file("bun.lockb") {
+        } else if app.includes_file("bun.lockb") || app.includes_file("bun.lock") {
             pkg_manager = "bun";
         }
         pkg_manager.to_string()
@@ -397,7 +408,7 @@ impl NodeProvider {
             }
         } else if app.includes_file("package-lock.json") {
             install_cmd = "npm ci".to_string();
-        } else if app.includes_file("bun.lockb") {
+        } else if app.includes_file("bun.lockb") || app.includes_file("bun.lock") {
             install_cmd = "bun i --no-save".to_string();
         }
 
@@ -961,6 +972,23 @@ mod test {
         Ok(())
     }
 
+    #[test]
+    fn test_version_from_node_version_file() -> Result<()> {
+        assert_eq!(
+            NodeProvider::get_nix_node_pkg(
+                &PackageJson {
+                    name: Some(String::default()),
+                    ..Default::default()
+                },
+                &App::new("examples/node-node-version")?,
+                &Environment::default()
+            )?,
+            Pkg::new("nodejs_22")
+        );
+
+        Ok(())
+    }
+
     #[test]
     fn test_version_from_nvmrc_lts() -> Result<()> {
         assert_eq!(
diff --git a/src/providers/python.rs b/src/providers/python.rs
index 30d1165..b6766f6 100644
--- a/src/providers/python.rs
+++ b/src/providers/python.rs
@@ -86,7 +86,6 @@ impl Provider for PythonProvider {
                 if let Some(poetry_version) =
                     PythonProvider::parse_tool_versions_poetry_version(file_content)?
                 {
-                    println!("Using poetry version from .tool-versions: {poetry_version}");
                     version = poetry_version;
                 }
             }
@@ -114,7 +113,6 @@ impl Provider for PythonProvider {
                 if let Some(uv_version) =
                     PythonProvider::parse_tool_versions_uv_version(file_content)?
                 {
-                    println!("Using uv version from .tool-versions: {uv_version}");
                     version = uv_version;
                 }
             }


Test patch:
diff --git a/examples/deno-jsonc/main_test.ts b/examples/deno-jsonc/main_test.ts
new file mode 100644
index 0000000..3d981e9
--- /dev/null
+++ b/examples/deno-jsonc/main_test.ts
@@ -0,0 +1,6 @@
+import { assertEquals } from "@std/assert";
+import { add } from "./main.ts";
+
+Deno.test(function addTest() {
+  assertEquals(add(2, 3), 5);
+});
diff --git a/tests/snapshots/generate_plan_tests__deno_jsonc.snap b/tests/snapshots/generate_plan_tests__deno_jsonc.snap
new file mode 100644
index 0000000..dd51556
--- /dev/null
+++ b/tests/snapshots/generate_plan_tests__deno_jsonc.snap
@@ -0,0 +1,24 @@
+---
+source: tests/generate_plan_tests.rs
+expression: plan
+---
+{
+  "providers": [],
+  "buildImage": "[build_image]",
+  "variables": {
+    "NIXPACKS_METADATA": "deno"
+  },
+  "phases": {
+    "setup": {
+      "name": "setup",
+      "nixPkgs": [
+        "deno"
+      ],
+      "nixOverlays": [],
+      "nixpkgsArchive": "[archive]"
+    }
+  },
+  "start": {
+    "cmd": "deno start main.ts"
+  }
+}
diff --git a/tests/snapshots/generate_plan_tests__node_node_version.snap b/tests/snapshots/generate_plan_tests__node_node_version.snap
new file mode 100644
index 0000000..a7f82aa
--- /dev/null
+++ b/tests/snapshots/generate_plan_tests__node_node_version.snap
@@ -0,0 +1,54 @@
+---
+source: tests/generate_plan_tests.rs
+expression: plan
+---
+{
+  "providers": [],
+  "buildImage": "[build_image]",
+  "variables": {
+    "CI": "true",
+    "NIXPACKS_METADATA": "node",
+    "NODE_ENV": "production",
+    "NPM_CONFIG_PRODUCTION": "false"
+  },
+  "phases": {
+    "build": {
+      "name": "build",
+      "dependsOn": [
+        "install"
+      ],
+      "cacheDirectories": [
+        "node_modules/.cache"
+      ]
+    },
+    "install": {
+      "name": "install",
+      "dependsOn": [
+        "setup"
+      ],
+      "cmds": [
+        "npm ci"
+      ],
+      "cacheDirectories": [
+        "/root/.npm"
+      ],
+      "paths": [
+        "/app/node_modules/.bin"
+      ]
+    },
+    "setup": {
+      "name": "setup",
+      "nixPkgs": [
+        "nodejs_22",
+        "npm-8_x"
+      ],
+      "nixOverlays": [
+        "https://github.com/railwayapp/nix-npm-overlay/archive/main.tar.gz"
+      ],
+      "nixpkgsArchive": "[archive]"
+    }
+  },
+  "start": {
+    "cmd": "npm run start"
+  }
+}