Repository: conan-io/conan

Issue:
[bug] Conan mixed requires which have different options
### Environment details

* Operating System+version: Windows 10
* Compiler+version: msvc 192
* Conan version: 2.0.17
* Python version:  3.11.5


### Steps to reproduce

1. My conan file:
```
from conan import ConanFile

class ConanTest(ConanFile):
    settings = "os", "arch", "compiler", "build_type"

    def requirements(self):
        self.requires("libpng/1.6.43")
        self.requires("zlib/1.3.1", options={"shared": True})
```
2. My build profile:
```
[settings]
os=Windows
arch=x86_64
```
3. My host profile
```
[settings]
os=Windows
arch=x86_64
compiler=msvc
compiler.version=192
compiler.runtime=dynamic
build_type=Release
```
4.  Run following command:
```
conan install test.py -pr:b build.profile -pr:h host.profile -r conancenter
```
5. Only zlib with shared=False is installed:
```
conan list zlib*#*:*

Local Cache
  zlib
    zlib/1.3.1
      revisions
        f52e03ae3d251dec704634230cd806a2 (2024-02-22 09:20:06 UTC)
          packages
            41ad450120fdab2266b1185a967d298f7ae52595
              info
                settings
                  arch: x86_64
                  build_type: Release
                  compiler: msvc
                  compiler.runtime: dynamic
                  compiler.runtime_type: Release
                  compiler.version: 192
                  os: Windows
                options
                  shared: False
```
The reason is conan doesn't force options to be equal while checking the equality of require here:
https://github.com/conan-io/conan/blob/bdefa4df7aa3888f87aebbe2f1db1384c6a448be/conans/model/requires.py#L218

so the zlib with options `shared=True` in my conanfile is overridden by libpng's zlib dependency, which has options `shared=False`.

### Logs

_No response_


Discussion:
Hi @zhanglin-zhou 

Thanks for your feedback.

It seems this is expected behavior. Conan follows a depth-first graph expansion, so dependencies that expand first will have higher priority.

In this case you have:
```python
 def requirements(self):
        self.requires("libpng/1.6.43")
        self.requires("zlib/1.3.1", options={"shared": True})
```
That means that ``libpng`` expansion will have higher priority, and if ``libpng`` by default is requiring ``zlib`` as static library, that will still have precedence. You can change the priority by:
```python
 def requirements(self):
        self.requires("zlib/1.3.1", options={"shared": True})
        self.requires("libpng/1.6.43")
```
Or by:
```python
 def requirements(self):
        self.requires("libpng/1.6.43", options={"zlib*:shared": True})
        self.requires("zlib/1.3.1", options={"shared": True})
```

In general, it is better recommended to define variability via ``options`` in the ``profiles`` files, not in recipes, as it guarantees the global definition of the options values.
I can understand what happen here, but I have concern about this behavior, because it doesn't warn me or throw error while this happen, zlib/1.3.1 with shared=True and zlib/1.3.1 with shared=False are two different packages, I have explicitly stated that I want a shared zlib in my conanfile, and it's replaced with a static zlib without any warning. I am lucky here because my product build process throws error while linking a static zlib, so I can note this issue. 
But considering other shared library with multiple options like openssl, how can I know which openssl will be used? Ok, the depth-first one, then where is the depth-first one come from? I need to go through all the packages in my conan file and its dependency to find when the openssl is first introduced, then check whether its options can satisfy my product's requirement.
This is really really crazy while building a complex product.
Actually I think while comparing two requires, the options must be compared too, because it does affect the package binary's behavior, and options belong to package itself, what do you think?
> I can understand what happen here, but I have concern about this behavior, because it doesn't warn me or throw error while this happen, zlib/1.3.1 with shared=True and zlib/1.3.1 with shared=False are two different packages, I have explicitly stated that I want a shared zlib in my conanfile, and it's replaced with a static zlib without any warning. I am lucky here because my product build process throws error while linking a static zlib, so I can note this issue.

The recommended way to always avoid using certain dependencies options is to implement a ``validate()`` method that raises if the upstream options aren't valid for the current package. Otherwise the current recipe ``options`` values definitions for dependencies are never enforced, it is just a low priority recommendation that will be followed if not overriding by downstream, and this recipe has higher priority than others (being required first).

> But considering other shared library with multiple options like openssl, how can I know which openssl will be used? Ok, the depth-first one, then where is the depth-first one come from? I need to go through all the packages in my conan file and its dependency to find when the openssl is first introduced, then check whether its options can satisfy my product's requirement.
This is really really crazy while building a complex product.

Recipes do not define options, recipes can only suggest options. The recommended way to guarantee some options values is defining them in the ``profile`` files, not in recipes.

> Actually I think while comparing two requires, the options must be compared too, because it does affect the package binary's behavior, and options belong to package itself, what do you think?

No, I am afraid that is not possible. The comparison of requires is designed to detect dependencies conflicts. Having different options will still cause conflicts, even if the requirements are considered different, because you cannot link 2 different variants of the same library even if they are built with different options. This is the reason why the comparison of requires is mainly based on the ``traits`` that cause compilation and linkage errors in C and C++, like headers, libraries or runtime libraries. Having 2 different dependencies of ``zlib`` just because one define ``with_minizip=True`` and the other doesn't is an error in the vast majority of cases.
> The recommended way to always avoid using certain dependencies options is to implement a `validate()` method that raises if the upstream options aren't valid for the current package. Otherwise the current recipe `options` values definitions for dependencies are never enforced, it is just a low priority recommendation that will be followed if not overriding by downstream, and this recipe has higher priority than others (being required first).

Yeah, I understand this behavior after several hours debugging. My point is, if you look back my conan file:
```
    def requirements(self):
        self.requires("libpng/1.6.43")
        self.requires("zlib/1.3.1", options={"shared": True})
```
and I get a static zlib finally, don't you think it's counterintuitive?

> Recipes do not define options, recipes can only suggest options. The recommended way to guarantee some options values is defining them in the `profile` files, not in recipes.

> because you cannot link 2 different variants of the same library even if they are built with different options. 

I guess you mean we cannot directly link 2 different variants of the same library into one executable file, right? Because there is case that an executable file links zlib dynamically, and then links libpng which links zlib statically. Also in our case, the product have multiple executable files, so different executable may requires different options.

Also my point is not about avoiding conflict, my point is, conan should warn me or give me error when this kind of conflict happen, instead of replacing the package silently.

> Also my point is not about avoiding conflict, my point is, conan should warn me or give me error when this kind of conflict happen, instead of replacing the package silently.

It might be possible for Conan to try to give some warning for this specific pattern with ``{"shared": True}``.
However, not that it is enough for the downstream consumer of this recipe to do ``{zlib/*:shared: True}`` and then even if you have that in your recipe, it will not even warn, because the ``self.requires(..., options=..`` will be overridden by the downstream even before checking the previous values of ``zlib`` options.

Let's see if it is possible to give that warning for this local use case.



Patch:
diff --git a/conan/cli/printers/graph.py b/conan/cli/printers/graph.py
index 6513636f8d4..404ca7c97f5 100644
--- a/conan/cli/printers/graph.py
+++ b/conan/cli/printers/graph.py
@@ -80,6 +80,17 @@ def _format_resolved(title, reqs_to_print):
             reason = f": {reason}" if reason else ""
             output.info("    {}{}".format(d, reason), Color.BRIGHT_CYAN)
 
+    if graph.options_conflicts:
+        output.info("Options conflicts", Color.BRIGHT_YELLOW)
+        for ref, ref_conflicts in graph.options_conflicts.items():
+            for option, conflict_info in ref_conflicts.items():
+                prev_value = conflict_info['value']
+                output.info(f"    {ref}:{option}={prev_value} (current value)", Color.BRIGHT_CYAN)
+                for src_ref, conflict_value in conflict_info["conflicts"]:
+                    output.info(f"        {src_ref}->{option}={conflict_value}", Color.BRIGHT_CYAN)
+        output.info("    It is recommended to define options values in profiles, not in recipes",
+                    Color.BRIGHT_CYAN)
+
 
 def print_graph_packages(graph):
     # I am excluding the "download"-"cache" or remote information, that is not
diff --git a/conans/client/graph/graph.py b/conans/client/graph/graph.py
index 63f79a41a34..ce1995e02d4 100644
--- a/conans/client/graph/graph.py
+++ b/conans/client/graph/graph.py
@@ -321,6 +321,7 @@ def __init__(self):
         self.aliased = {}
         self.resolved_ranges = {}
         self.replaced_requires = {}
+        self.options_conflicts = {}
         self.error = False
 
     def overrides(self):
diff --git a/conans/client/graph/graph_builder.py b/conans/client/graph/graph_builder.py
index e190fd1e61d..937d32f76a2 100644
--- a/conans/client/graph/graph_builder.py
+++ b/conans/client/graph/graph_builder.py
@@ -13,7 +13,7 @@
 from conans.client.graph.provides import check_graph_provides
 from conans.errors import ConanException
 from conans.model.conan_file import ConanFile
-from conans.model.options import Options
+from conans.model.options import Options, _PackageOptions
 from conans.model.pkg_type import PackageType
 from conans.model.recipe_ref import RecipeReference, ref_matches
 from conans.model.requires import Requirement
@@ -97,10 +97,31 @@ def _expand_require(self, require, node, graph, profile_host, profile_build, gra
             # print("Closing a loop from ", node, "=>", prev_node)
             # Keep previous "test" status only if current is also test
             prev_node.test = prev_node.test and (node.test or require.test)
+            self._save_options_conflicts(node, require, prev_node, graph)
             require.process_package_type(node, prev_node)
             graph.add_edge(node, prev_node, require)
             node.propagate_closing_loop(require, prev_node)
 
+    def _save_options_conflicts(self, node, require, prev_node, graph):
+        """ Store the discrepancies of options when closing a diamond, to later report
+        them. This list is not exhaustive, only the diamond vertix, not other transitives
+        """
+        down_options = self._compute_down_options(node, require, prev_node.ref)
+        down_options = down_options._deps_package_options  # noqa
+        if not down_options:
+            return
+        down_pkg_options = _PackageOptions()
+        for pattern, options in down_options.items():
+            if ref_matches(prev_node.ref, pattern, is_consumer=False):
+                down_pkg_options.update_options(options)
+        prev_options = {k: v for k, v in prev_node.conanfile.options.items()}
+        for k, v in down_pkg_options.items():
+            prev_value = prev_options.get(k)
+            if prev_value is not None and prev_value != v:
+                d = graph.options_conflicts.setdefault(str(prev_node.ref), {})
+                conflicts = d.setdefault(k, {"value": prev_value}).setdefault("conflicts", [])
+                conflicts.append((node.ref, v))
+
     @staticmethod
     def _conflicting_version(require, node,
                              prev_require, prev_node, prev_ref, base_previous, resolve_prereleases):
@@ -334,6 +355,29 @@ def _create_new_node(self, node, require, graph, profile_host, profile_build, gr
         new_node.recipe = recipe_status
         new_node.remote = remote
 
+        down_options = self._compute_down_options(node, require, new_ref)
+
+        if recipe_status != RECIPE_PLATFORM:
+            self._prepare_node(new_node, profile_host, profile_build, down_options)
+        if dep_conanfile.package_type is PackageType.CONF and node.recipe != RECIPE_VIRTUAL:
+            raise ConanException(f"Configuration package {dep_conanfile} cannot be used as "
+                                 f"requirement, but {node.ref} is requiring it")
+
+        require.process_package_type(node, new_node)
+        graph.add_node(new_node)
+        graph.add_edge(node, new_node, require)
+        if node.propagate_downstream(require, new_node):
+            raise GraphRuntimeError(node, new_node)
+
+        # This is necessary to prevent infinite loops even when visibility is False
+        ancestor = node.check_loops(new_node)
+        if ancestor is not None:
+            raise GraphLoopError(new_node, require, ancestor)
+
+        return new_node
+
+    @staticmethod
+    def _compute_down_options(node, require, new_ref):
         # The consumer "up_options" are the options that come from downstream to this node
         if require.options is not None:
             # If the consumer has specified "requires(options=xxx)", we need to use it
@@ -354,25 +398,7 @@ def _create_new_node(self, node, require, graph, profile_host, profile_build, gr
                 down_options = node.conanfile.private_up_options
             else:
                 down_options = Options(options_values=node.conanfile.default_build_options)
-
-        if recipe_status != RECIPE_PLATFORM:
-            self._prepare_node(new_node, profile_host, profile_build, down_options)
-        if dep_conanfile.package_type is PackageType.CONF and node.recipe != RECIPE_VIRTUAL:
-            raise ConanException(f"Configuration package {dep_conanfile} cannot be used as "
-                                 f"requirement, but {node.ref} is requiring it")
-
-        require.process_package_type(node, new_node)
-        graph.add_node(new_node)
-        graph.add_edge(node, new_node, require)
-        if node.propagate_downstream(require, new_node):
-            raise GraphRuntimeError(node, new_node)
-
-        # This is necessary to prevent infinite loops even when visibility is False
-        ancestor = node.check_loops(new_node)
-        if ancestor is not None:
-            raise GraphLoopError(new_node, require, ancestor)
-
-        return new_node
+        return down_options
 
     @staticmethod
     def _remove_overrides(dep_graph):


Test patch:
diff --git a/conans/test/integration/options/options_test.py b/conans/test/integration/options/options_test.py
index 9a7b417b085..65f4d77e794 100644
--- a/conans/test/integration/options/options_test.py
+++ b/conans/test/integration/options/options_test.py
@@ -739,3 +739,30 @@ def test_wrong_option_syntax_no_trace(self):
         tc.run('create . -o="&:myoption"', assert_error=True)
         assert "ValueError" not in tc.out
         assert "Error while parsing option" in tc.out
+
+
+class TestConflictOptionsWarnings:
+
+    def test_options_warnings(self):
+        c = TestClient()
+        liba = GenConanfile("liba", "0.1").with_option("myoption", [1, 2, 3], default=1)
+        libb = GenConanfile("libb", "0.1").with_requires("liba/0.1")
+        libc = GenConanfile("libc", "0.1").with_requirement("liba/0.1", options={"myoption": 2})
+        app = GenConanfile().with_requires("libb/0.1", "libc/0.1")
+
+        c.save({"liba/conanfile.py": liba,
+                "libb/conanfile.py": libb,
+                "libc/conanfile.py": libc,
+                "app/conanfile.py": app})
+        c.run("export liba")
+        c.run("export libb")
+        c.run("export libc")
+
+        c.run("graph info app")
+        expected = textwrap.dedent("""\
+            Options conflicts
+                liba/0.1:myoption=1 (current value)
+                    libc/0.1->myoption=2
+                It is recommended to define options values in profiles, not in recipes
+            """)
+        assert expected in c.out