• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

devonfw / IDEasy / 24984011544

27 Apr 2026 08:14AM UTC coverage: 70.718% (+0.08%) from 70.641%
24984011544

Pull #1856

github

web-flow
Merge f59d7af1d into 344d6c0f7
Pull Request #1856: #1643 improve ux on syntax error

4403 of 6878 branches covered (64.02%)

Branch coverage included in aggregate %.

11348 of 15395 relevant lines covered (73.71%)

3.12 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

66.15
cli/src/main/java/com/devonfw/tools/ide/context/AbstractIdeContext.java
1
package com.devonfw.tools.ide.context;
2

3
import static com.devonfw.tools.ide.variable.IdeVariables.IDE_MIN_VERSION;
4

5
import java.io.ByteArrayInputStream;
6
import java.io.ByteArrayOutputStream;
7
import java.io.IOException;
8
import java.nio.file.Files;
9
import java.nio.file.Path;
10
import java.time.LocalDateTime;
11
import java.util.ArrayList;
12
import java.util.HashMap;
13
import java.util.Iterator;
14
import java.util.List;
15
import java.util.Locale;
16
import java.util.Map;
17
import java.util.Map.Entry;
18
import java.util.Objects;
19
import java.util.Properties;
20
import java.util.function.Predicate;
21
import java.util.logging.FileHandler;
22
import java.util.logging.LogManager;
23
import java.util.logging.SimpleFormatter;
24

25
import org.slf4j.Logger;
26
import org.slf4j.LoggerFactory;
27

28
import com.devonfw.tools.ide.cli.CliAbortException;
29
import com.devonfw.tools.ide.cli.CliArgument;
30
import com.devonfw.tools.ide.cli.CliArguments;
31
import com.devonfw.tools.ide.cli.CliException;
32
import com.devonfw.tools.ide.commandlet.Commandlet;
33
import com.devonfw.tools.ide.commandlet.CommandletManager;
34
import com.devonfw.tools.ide.commandlet.CommandletManagerImpl;
35
import com.devonfw.tools.ide.commandlet.ContextCommandlet;
36
import com.devonfw.tools.ide.commandlet.EnvironmentCommandlet;
37
import com.devonfw.tools.ide.commandlet.UpdateCommandlet;
38
import com.devonfw.tools.ide.common.SystemPath;
39
import com.devonfw.tools.ide.completion.CompletionCandidate;
40
import com.devonfw.tools.ide.completion.CompletionCandidateCollector;
41
import com.devonfw.tools.ide.completion.CompletionCandidateCollectorDefault;
42
import com.devonfw.tools.ide.environment.AbstractEnvironmentVariables;
43
import com.devonfw.tools.ide.environment.EnvironmentVariables;
44
import com.devonfw.tools.ide.environment.EnvironmentVariablesType;
45
import com.devonfw.tools.ide.environment.IdeSystem;
46
import com.devonfw.tools.ide.environment.IdeSystemImpl;
47
import com.devonfw.tools.ide.git.GitContext;
48
import com.devonfw.tools.ide.git.GitContextImpl;
49
import com.devonfw.tools.ide.git.GitUrl;
50
import com.devonfw.tools.ide.io.FileAccess;
51
import com.devonfw.tools.ide.io.FileAccessImpl;
52
import com.devonfw.tools.ide.log.IdeLogArgFormatter;
53
import com.devonfw.tools.ide.log.IdeLogLevel;
54
import com.devonfw.tools.ide.log.IdeLogListener;
55
import com.devonfw.tools.ide.log.JulConsoleHandler;
56
import com.devonfw.tools.ide.merge.DirectoryMerger;
57
import com.devonfw.tools.ide.migration.IdeMigrator;
58
import com.devonfw.tools.ide.network.NetworkStatus;
59
import com.devonfw.tools.ide.network.NetworkStatusImpl;
60
import com.devonfw.tools.ide.os.SystemInfo;
61
import com.devonfw.tools.ide.os.SystemInfoImpl;
62
import com.devonfw.tools.ide.os.WindowsHelper;
63
import com.devonfw.tools.ide.os.WindowsHelperImpl;
64
import com.devonfw.tools.ide.os.WindowsPathSyntax;
65
import com.devonfw.tools.ide.process.ProcessContext;
66
import com.devonfw.tools.ide.process.ProcessContextImpl;
67
import com.devonfw.tools.ide.process.ProcessResult;
68
import com.devonfw.tools.ide.property.Property;
69
import com.devonfw.tools.ide.step.Step;
70
import com.devonfw.tools.ide.step.StepImpl;
71
import com.devonfw.tools.ide.tool.custom.CustomToolRepository;
72
import com.devonfw.tools.ide.tool.custom.CustomToolRepositoryImpl;
73
import com.devonfw.tools.ide.tool.mvn.MvnRepository;
74
import com.devonfw.tools.ide.tool.npm.NpmRepository;
75
import com.devonfw.tools.ide.tool.pip.PipRepository;
76
import com.devonfw.tools.ide.tool.repository.DefaultToolRepository;
77
import com.devonfw.tools.ide.tool.repository.ToolRepository;
78
import com.devonfw.tools.ide.url.model.UrlMetadata;
79
import com.devonfw.tools.ide.util.DateTimeUtil;
80
import com.devonfw.tools.ide.util.PrivacyUtil;
81
import com.devonfw.tools.ide.validation.ValidationResult;
82
import com.devonfw.tools.ide.validation.ValidationResultValid;
83
import com.devonfw.tools.ide.validation.ValidationState;
84
import com.devonfw.tools.ide.variable.IdeVariables;
85
import com.devonfw.tools.ide.version.IdeVersion;
86
import com.devonfw.tools.ide.version.VersionIdentifier;
87

88
/**
89
 * Abstract base implementation of {@link IdeContext}.
90
 */
91
public abstract class AbstractIdeContext implements IdeContext, IdeLogArgFormatter {
92

93
  static final Logger LOG = LoggerFactory.getLogger(AbstractIdeContext.class);
3✔
94

95
  /** The default shell bash (Bourne Again SHell). */
96
  public static final String BASH = "bash";
97

98
  private static final GitUrl IDE_URLS_GIT = new GitUrl("https://github.com/devonfw/ide-urls.git", null);
7✔
99

100
  private static final String LICENSE_URL = "https://github.com/devonfw/IDEasy/blob/main/documentation/LICENSE.adoc";
101

102
  private static final String DEFAULT_WINDOWS_GIT_PATH = "C:\\Program Files\\Git\\bin\\bash.exe";
103

104
  private static final String OPTION_DETAILS_START = "([";
105

106
  private final IdeStartContextImpl startContext;
107

108
  private Path ideHome;
109

110
  private final Path ideRoot;
111

112
  private Path confPath;
113

114
  protected Path settingsPath;
115

116
  private Path settingsCommitIdPath;
117

118
  protected Path pluginsPath;
119

120
  private Path workspacePath;
121

122
  private Path workspacesBasePath;
123

124
  private String workspaceName;
125

126
  private Path cwd;
127

128
  private Path downloadPath;
129

130
  private Path userHome;
131

132
  private Path userHomeIde;
133

134
  private SystemPath path;
135

136
  private WindowsPathSyntax pathSyntax;
137

138
  private final SystemInfo systemInfo;
139

140
  private EnvironmentVariables variables;
141

142
  private final FileAccess fileAccess;
143

144
  protected CommandletManager commandletManager;
145

146
  protected ToolRepository defaultToolRepository;
147

148
  private CustomToolRepository customToolRepository;
149

150
  private MvnRepository mvnRepository;
151

152
  private NpmRepository npmRepository;
153

154
  private PipRepository pipRepository;
155

156
  private DirectoryMerger workspaceMerger;
157

158
  protected UrlMetadata urlMetadata;
159

160
  protected Path defaultExecutionDirectory;
161

162
  private StepImpl currentStep;
163

164
  private NetworkStatus networkStatus;
165

166
  protected IdeSystem system;
167

168
  private WindowsHelper windowsHelper;
169

170
  private final Map<String, String> privacyMap;
171

172
  private Path bash;
173

174
  private boolean julConfigured;
175

176
  private Path logfile;
177

178
  /**
179
   * The constructor.
180
   *
181
   * @param startContext the {@link IdeStartContextImpl}.
182
   * @param workingDirectory the optional {@link Path} to current working directory.
183
   */
184
  public AbstractIdeContext(IdeStartContextImpl startContext, Path workingDirectory) {
185

186
    super();
2✔
187
    this.startContext = startContext;
3✔
188
    this.startContext.setArgFormatter(this);
4✔
189
    this.privacyMap = new HashMap<>();
5✔
190
    this.systemInfo = SystemInfoImpl.INSTANCE;
3✔
191
    if (isTest()) {
3!
192
      configureJavaUtilLogging(null);
3✔
193
    }
194
    this.commandletManager = new CommandletManagerImpl(this);
6✔
195
    this.fileAccess = new FileAccessImpl(this);
6✔
196
    String userHomeProperty = getSystem().getProperty("user.home");
5✔
197
    if (userHomeProperty != null) {
2!
198
      this.userHome = Path.of(userHomeProperty);
×
199
    }
200
    if (workingDirectory == null) {
2!
201
      workingDirectory = Path.of(System.getProperty("user.dir"));
×
202
    }
203
    workingDirectory = workingDirectory.toAbsolutePath();
3✔
204
    if (Files.isDirectory(workingDirectory)) {
5✔
205
      workingDirectory = this.fileAccess.toCanonicalPath(workingDirectory);
6✔
206
    } else {
207
      LOG.warn("Current working directory does not exist: {}", workingDirectory);
4✔
208
    }
209
    this.cwd = workingDirectory;
3✔
210
    // detect IDE_HOME and WORKSPACE
211
    String workspace = null;
2✔
212
    Path ideHomeDir = null;
2✔
213
    IdeHomeAndWorkspace ideHomeAndWorkspace = findIdeHome(workingDirectory);
4✔
214
    if (ideHomeAndWorkspace != null) {
2!
215
      ideHomeDir = ideHomeAndWorkspace.home();
3✔
216
      workspace = ideHomeAndWorkspace.workspace();
3✔
217
    }
218

219
    // detection completed, initializing variables
220
    this.ideRoot = findIdeRoot(ideHomeDir);
5✔
221

222
    setCwd(workingDirectory, workspace, ideHomeDir);
5✔
223

224
    if (this.ideRoot != null) {
3✔
225
      Path tempDownloadPath = getTempDownloadPath();
3✔
226
      if (Files.isDirectory(tempDownloadPath)) {
6✔
227
        // TODO delete all files older than 1 day here...
228
      } else {
229
        this.fileAccess.mkdirs(tempDownloadPath);
4✔
230
      }
231
    }
232
    this.defaultToolRepository = new DefaultToolRepository(this);
6✔
233
  }
1✔
234

235
  /**
236
   * Searches for the IDE home directory by traversing up the directory tree from the given working directory. This method can be overridden in test contexts to
237
   * add additional validation or boundary checks.
238
   *
239
   * @param workingDirectory the starting directory for the search.
240
   * @return an instance of {@link IdeHomeAndWorkspace} where the IDE_HOME was found or {@code null} if not found.
241
   */
242
  protected IdeHomeAndWorkspace findIdeHome(Path workingDirectory) {
243

244
    Path currentDir = workingDirectory;
2✔
245
    String name1 = "";
2✔
246
    String name2 = "";
2✔
247
    String workspace = WORKSPACE_MAIN;
2✔
248
    Path ideRootPath = getIdeRootPathFromEnv(false);
4✔
249

250
    while (currentDir != null) {
2✔
251
      LOG.trace("Looking for IDE_HOME in {}", currentDir);
4✔
252
      if (isIdeHome(currentDir)) {
4✔
253
        if (FOLDER_WORKSPACES.equals(name1) && !name2.isEmpty()) {
7✔
254
          workspace = name2;
3✔
255
        }
256
        break;
257
      }
258
      name2 = name1;
2✔
259
      int nameCount = currentDir.getNameCount();
3✔
260
      if (nameCount >= 1) {
3✔
261
        name1 = currentDir.getName(nameCount - 1).toString();
7✔
262
      }
263
      currentDir = currentDir.getParent();
3✔
264
      if ((ideRootPath != null) && (ideRootPath.equals(currentDir))) {
2!
265
        // prevent that during tests we traverse to the real IDE project of IDEasy developer
266
        currentDir = null;
×
267
      }
268
    }
1✔
269

270
    return new IdeHomeAndWorkspace(currentDir, workspace);
6✔
271
  }
272

273
  /**
274
   * @return a new {@link MvnRepository}
275
   */
276
  protected MvnRepository createMvnRepository() {
277
    return new MvnRepository(this);
5✔
278
  }
279

280
  /**
281
   * @return a new {@link NpmRepository}
282
   */
283
  protected NpmRepository createNpmRepository() {
284
    return new NpmRepository(this);
×
285
  }
286

287
  /**
288
   * @return a new {@link PipRepository}
289
   */
290
  protected PipRepository createPipRepository() {
291
    return new PipRepository(this);
×
292
  }
293

294
  private Path findIdeRoot(Path ideHomePath) {
295

296
    Path ideRootPath = null;
2✔
297
    if (ideHomePath != null) {
2✔
298
      Path ideRootPathFromEnv = getIdeRootPathFromEnv(true);
4✔
299
      ideRootPath = ideHomePath.getParent();
3✔
300
      if ((ideRootPathFromEnv != null) && !ideRootPath.toString().equals(ideRootPathFromEnv.toString())) {
2!
301
        LOG.warn(
×
302
            "Variable IDE_ROOT is set to '{}' but for your project '{}' the path '{}' would have been expected.\n"
303
                + "Please check your 'user.dir' or working directory setting and make sure that it matches your IDE_ROOT variable.",
304
            ideRootPathFromEnv,
305
            ideHomePath.getFileName(), ideRootPath);
×
306
      }
307
    } else if (!isTest()) {
4!
308
      ideRootPath = getIdeRootPathFromEnv(true);
×
309
    }
310
    return ideRootPath;
2✔
311
  }
312

313
  /**
314
   * @return the {@link #getIdeRoot() IDE_ROOT} from the system environment.
315
   */
316
  protected Path getIdeRootPathFromEnv(boolean withSanityCheck) {
317

318
    String root = getSystem().getEnv(IdeVariables.IDE_ROOT.getName());
×
319
    if (root != null) {
×
320
      Path rootPath = Path.of(root);
×
321
      if (Files.isDirectory(rootPath)) {
×
322
        Path absoluteRootPath = getFileAccess().toCanonicalPath(rootPath);
×
323
        if (withSanityCheck) {
×
324
          int nameCount = rootPath.getNameCount();
×
325
          int absoluteNameCount = absoluteRootPath.getNameCount();
×
326
          int delta = absoluteNameCount - nameCount;
×
327
          if (delta >= 0) {
×
328
            for (int nameIndex = 0; nameIndex < nameCount; nameIndex++) {
×
329
              String rootName = rootPath.getName(nameIndex).toString();
×
330
              String absoluteRootName = absoluteRootPath.getName(nameIndex + delta).toString();
×
331
              if (!rootName.equals(absoluteRootName)) {
×
332
                LOG.warn("IDE_ROOT is set to {} but was expanded to absolute path {} and does not match for segment {} and {} - fix your IDEasy installation!",
×
333
                    rootPath, absoluteRootPath, rootName, absoluteRootName);
334
                break;
×
335
              }
336
            }
337
          } else {
338
            LOG.warn("IDE_ROOT is set to {} but was expanded to a shorter absolute path {}", rootPath,
×
339
                absoluteRootPath);
340
          }
341
        }
342
        return absoluteRootPath;
×
343
      } else if (withSanityCheck) {
×
344
        LOG.warn("IDE_ROOT is set to {} that is not an existing directory - fix your IDEasy installation!", rootPath);
×
345
      }
346
    }
347
    return null;
×
348
  }
349

350
  @Override
351
  public void setCwd(Path userDir, String workspace, Path ideHome) {
352

353
    this.cwd = userDir;
3✔
354
    this.workspaceName = workspace;
3✔
355
    this.ideHome = ideHome;
3✔
356
    if (ideHome == null) {
2✔
357
      this.workspacesBasePath = null;
3✔
358
      this.workspacePath = null;
3✔
359
      this.confPath = null;
3✔
360
      this.settingsPath = null;
3✔
361
      this.pluginsPath = null;
4✔
362
    } else {
363
      this.workspacesBasePath = this.ideHome.resolve(FOLDER_WORKSPACES);
6✔
364
      this.workspacePath = this.workspacesBasePath.resolve(this.workspaceName);
7✔
365
      this.confPath = this.ideHome.resolve(FOLDER_CONF);
6✔
366
      this.settingsPath = this.ideHome.resolve(FOLDER_SETTINGS);
6✔
367
      this.settingsCommitIdPath = this.ideHome.resolve(IdeContext.SETTINGS_COMMIT_ID);
6✔
368
      this.pluginsPath = this.ideHome.resolve(FOLDER_PLUGINS);
6✔
369
    }
370
    if (isTest()) {
3!
371
      // only for testing...
372
      if (this.ideHome == null) {
3✔
373
        this.userHome = Path.of("/non-existing-user-home-for-testing");
7✔
374
      } else {
375
        this.userHome = this.ideHome.resolve("home");
6✔
376
      }
377
    }
378
    this.userHomeIde = this.userHome.resolve(FOLDER_DOT_IDE);
6✔
379
    this.downloadPath = this.userHome.resolve("Downloads/ide");
6✔
380
    resetPrivacyMap();
2✔
381
    this.path = computeSystemPath();
4✔
382
  }
1✔
383

384
  private String getMessageIdeHomeFound() {
385

386
    String wks = this.workspaceName;
3✔
387
    if (isPrivacyMode() && !WORKSPACE_MAIN.equals(wks)) {
3!
388
      wks = "*".repeat(wks.length());
×
389
    }
390
    return "IDE environment variables have been set for " + formatArgument(this.ideHome) + " in workspace " + wks;
7✔
391
  }
392

393
  private String getMessageNotInsideIdeProject() {
394

395
    return "You are not inside an IDE project: " + formatArgument(this.cwd);
6✔
396
  }
397

398
  private String getMessageIdeRootNotFound() {
399

400
    String root = getSystem().getEnv("IDE_ROOT");
5✔
401
    if (root == null) {
2!
402
      return "The environment variable IDE_ROOT is undefined. Please reinstall IDEasy or manually repair IDE_ROOT variable.";
2✔
403
    } else {
404
      return "The environment variable IDE_ROOT is pointing to an invalid path " + formatArgument(root)
×
405
          + ". Please reinstall IDEasy or manually repair IDE_ROOT variable.";
406
    }
407
  }
408

409
  /**
410
   * @return {@code true} if this is a test context for JUnits, {@code false} otherwise.
411
   */
412
  public boolean isTest() {
413

414
    return false;
×
415
  }
416

417
  protected SystemPath computeSystemPath() {
418

419
    return new SystemPath(this);
×
420
  }
421

422
  /**
423
   * Checks if the given directory is a valid IDE home by verifying it contains both 'workspaces' and 'settings' directories.
424
   *
425
   * @param dir the directory to check.
426
   * @return {@code true} if the directory is a valid IDE home, {@code false} otherwise.
427
   */
428
  protected boolean isIdeHome(Path dir) {
429

430
    if (!Files.isDirectory(dir.resolve("workspaces"))) {
7✔
431
      return false;
2✔
432
    } else if (!Files.isDirectory(dir.resolve("settings"))) {
7!
433
      return false;
×
434
    }
435
    return true;
2✔
436
  }
437

438
  private EnvironmentVariables createVariables() {
439

440
    AbstractEnvironmentVariables system = createSystemVariables();
3✔
441
    AbstractEnvironmentVariables user = system.extend(this.userHomeIde, EnvironmentVariablesType.USER);
6✔
442
    AbstractEnvironmentVariables settings = user.extend(this.settingsPath, EnvironmentVariablesType.SETTINGS);
6✔
443
    AbstractEnvironmentVariables workspace = settings.extend(this.workspacePath, EnvironmentVariablesType.WORKSPACE);
6✔
444
    AbstractEnvironmentVariables conf = workspace.extend(this.confPath, EnvironmentVariablesType.CONF);
6✔
445
    return conf.resolved();
3✔
446
  }
447

448
  protected AbstractEnvironmentVariables createSystemVariables() {
449

450
    return EnvironmentVariables.ofSystem(this);
3✔
451
  }
452

453
  @Override
454
  public SystemInfo getSystemInfo() {
455

456
    return this.systemInfo;
3✔
457
  }
458

459
  @Override
460
  public FileAccess getFileAccess() {
461

462
    return this.fileAccess;
3✔
463
  }
464

465
  @Override
466
  public CommandletManager getCommandletManager() {
467

468
    return this.commandletManager;
3✔
469
  }
470

471
  @Override
472
  public ToolRepository getDefaultToolRepository() {
473

474
    return this.defaultToolRepository;
3✔
475
  }
476

477
  @Override
478
  public MvnRepository getMvnRepository() {
479
    if (this.mvnRepository == null) {
3✔
480
      this.mvnRepository = createMvnRepository();
4✔
481
    }
482
    return this.mvnRepository;
3✔
483
  }
484

485
  @Override
486
  public NpmRepository getNpmRepository() {
487
    if (this.npmRepository == null) {
3✔
488
      this.npmRepository = createNpmRepository();
4✔
489
    }
490
    return this.npmRepository;
3✔
491
  }
492

493
  @Override
494
  public PipRepository getPipRepository() {
495
    if (this.pipRepository == null) {
3✔
496
      this.pipRepository = createPipRepository();
4✔
497
    }
498
    return this.pipRepository;
3✔
499
  }
500

501
  @Override
502
  public CustomToolRepository getCustomToolRepository() {
503

504
    if (this.customToolRepository == null) {
3!
505
      this.customToolRepository = CustomToolRepositoryImpl.of(this);
4✔
506
    }
507
    return this.customToolRepository;
3✔
508
  }
509

510
  @Override
511
  public Path getIdeHome() {
512

513
    return this.ideHome;
3✔
514
  }
515

516
  @Override
517
  public String getProjectName() {
518

519
    if (this.ideHome != null) {
3!
520
      return this.ideHome.getFileName().toString();
5✔
521
    }
522
    return "";
×
523
  }
524

525
  @Override
526
  public VersionIdentifier getProjectVersion() {
527

528
    if (this.ideHome != null) {
3!
529
      Path versionFile = this.ideHome.resolve(IdeContext.FILE_SOFTWARE_VERSION);
5✔
530
      if (Files.exists(versionFile)) {
5✔
531
        String version = this.fileAccess.readFileContent(versionFile).trim();
6✔
532
        return VersionIdentifier.of(version);
3✔
533
      }
534
    }
535
    return IdeMigrator.START_VERSION;
2✔
536
  }
537

538
  @Override
539
  public void setProjectVersion(VersionIdentifier version) {
540

541
    if (this.ideHome == null) {
3!
542
      throw new IllegalStateException("IDE_HOME not available!");
×
543
    }
544
    Objects.requireNonNull(version);
3✔
545
    Path versionFile = this.ideHome.resolve(IdeContext.FILE_SOFTWARE_VERSION);
5✔
546
    this.fileAccess.writeFileContent(version.toString(), versionFile);
6✔
547
  }
1✔
548

549
  @Override
550
  public Path getIdeRoot() {
551

552
    return this.ideRoot;
3✔
553
  }
554

555
  @Override
556
  public Path getIdePath() {
557

558
    Path myIdeRoot = getIdeRoot();
3✔
559
    if (myIdeRoot == null) {
2✔
560
      return null;
2✔
561
    }
562
    return myIdeRoot.resolve(FOLDER_UNDERSCORE_IDE);
4✔
563
  }
564

565
  @Override
566
  public Path getCwd() {
567

568
    return this.cwd;
3✔
569
  }
570

571
  @Override
572
  public Path getTempPath() {
573

574
    Path idePath = getIdePath();
3✔
575
    if (idePath == null) {
2!
576
      return null;
×
577
    }
578
    return idePath.resolve("tmp");
4✔
579
  }
580

581
  @Override
582
  public Path getTempDownloadPath() {
583

584
    Path tmp = getTempPath();
3✔
585
    if (tmp == null) {
2!
586
      return null;
×
587
    }
588
    return tmp.resolve(FOLDER_DOWNLOADS);
4✔
589
  }
590

591
  @Override
592
  public Path getUserHome() {
593

594
    return this.userHome;
3✔
595
  }
596

597
  /**
598
   * This method should only be used for tests to mock user home.
599
   *
600
   * @param userHome the new value of {@link #getUserHome()}.
601
   */
602
  protected void setUserHome(Path userHome) {
603

604
    this.userHome = userHome;
3✔
605
    this.userHomeIde = userHome.resolve(FOLDER_DOT_IDE);
5✔
606
    this.downloadPath = userHome.resolve("Downloads/ide");
5✔
607
    this.variables = null;
3✔
608
    resetPrivacyMap();
2✔
609
  }
1✔
610

611
  @Override
612
  public Path getUserHomeIde() {
613

614
    return this.userHomeIde;
3✔
615
  }
616

617
  @Override
618
  public Path getSettingsPath() {
619

620
    return this.settingsPath;
3✔
621
  }
622

623
  @Override
624
  public Path getSettingsGitRepository() {
625

626
    Path settingsPath = getSettingsPath();
3✔
627
    // check whether the settings path has a .git folder only if its not a symbolic link or junction
628
    if ((settingsPath != null) && !Files.exists(settingsPath.resolve(".git")) && !isSettingsRepositorySymlinkOrJunction()) {
12!
629
      LOG.error("Settings repository exists but is not a git repository.");
3✔
630
      return null;
2✔
631
    }
632
    return settingsPath;
2✔
633
  }
634

635
  @Override
636
  public boolean isSettingsRepositorySymlinkOrJunction() {
637

638
    Path settingsPath = getSettingsPath();
3✔
639
    if (settingsPath == null) {
2!
640
      return false;
×
641
    }
642
    return Files.isSymbolicLink(settingsPath) || getFileAccess().isJunction(settingsPath);
10!
643
  }
644

645
  @Override
646
  public Path getSettingsCommitIdPath() {
647

648
    return this.settingsCommitIdPath;
3✔
649
  }
650

651
  @Override
652
  public Path getConfPath() {
653

654
    return this.confPath;
3✔
655
  }
656

657
  @Override
658
  public Path getSoftwarePath() {
659

660
    if (this.ideHome == null) {
3✔
661
      return null;
2✔
662
    }
663
    return this.ideHome.resolve(FOLDER_SOFTWARE);
5✔
664
  }
665

666
  @Override
667
  public Path getSoftwareExtraPath() {
668

669
    Path softwarePath = getSoftwarePath();
3✔
670
    if (softwarePath == null) {
2!
671
      return null;
×
672
    }
673
    return softwarePath.resolve(FOLDER_EXTRA);
4✔
674
  }
675

676
  @Override
677
  public Path getSoftwareRepositoryPath() {
678

679
    Path idePath = getIdePath();
3✔
680
    if (idePath == null) {
2!
681
      return null;
×
682
    }
683
    return idePath.resolve(FOLDER_SOFTWARE);
4✔
684
  }
685

686
  @Override
687
  public Path getPluginsPath() {
688

689
    return this.pluginsPath;
3✔
690
  }
691

692
  @Override
693
  public String getWorkspaceName() {
694

695
    return this.workspaceName;
3✔
696
  }
697

698
  @Override
699
  public Path getWorkspacesBasePath() {
700

701
    return this.workspacesBasePath;
3✔
702
  }
703

704
  @Override
705
  public Path getWorkspacePath() {
706

707
    return this.workspacePath;
3✔
708
  }
709

710
  @Override
711
  public Path getWorkspacePath(String workspace) {
712

713
    if (this.workspacesBasePath == null) {
3!
714
      throw new IllegalStateException("Failed to access workspace " + workspace + " without IDE_HOME in " + this.cwd);
×
715
    }
716
    return this.workspacesBasePath.resolve(workspace);
5✔
717
  }
718

719
  @Override
720
  public Path getDownloadPath() {
721

722
    return this.downloadPath;
3✔
723
  }
724

725
  @Override
726
  public Path getUrlsPath() {
727

728
    Path idePath = getIdePath();
3✔
729
    if (idePath == null) {
2!
730
      return null;
×
731
    }
732
    return idePath.resolve(FOLDER_URLS);
4✔
733
  }
734

735
  @Override
736
  public Path getToolRepositoryPath() {
737

738
    Path idePath = getIdePath();
3✔
739
    if (idePath == null) {
2!
740
      return null;
×
741
    }
742
    return idePath.resolve(FOLDER_SOFTWARE);
4✔
743
  }
744

745
  @Override
746
  public SystemPath getPath() {
747

748
    return this.path;
3✔
749
  }
750

751
  @Override
752
  public EnvironmentVariables getVariables() {
753

754
    if (this.variables == null) {
3✔
755
      this.variables = createVariables();
4✔
756
    }
757
    return this.variables;
3✔
758
  }
759

760
  @Override
761
  public UrlMetadata getUrls() {
762

763
    if (this.urlMetadata == null) {
3✔
764
      if (!isTest()) {
3!
765
        getGitContext().pullOrCloneAndResetIfNeeded(IDE_URLS_GIT, getUrlsPath(), null);
×
766
      }
767
      this.urlMetadata = new UrlMetadata(this);
6✔
768
    }
769
    return this.urlMetadata;
3✔
770
  }
771

772
  @Override
773
  public boolean isQuietMode() {
774

775
    return this.startContext.isQuietMode();
4✔
776
  }
777

778
  @Override
779
  public boolean isBatchMode() {
780

781
    return this.startContext.isBatchMode();
4✔
782
  }
783

784
  @Override
785
  public boolean isForceMode() {
786

787
    return this.startContext.isForceMode();
4✔
788
  }
789

790
  @Override
791
  public boolean isForcePull() {
792

793
    return this.startContext.isForcePull();
4✔
794
  }
795

796
  @Override
797
  public boolean isForcePlugins() {
798

799
    return this.startContext.isForcePlugins();
4✔
800
  }
801

802
  @Override
803
  public boolean isForceRepositories() {
804

805
    return this.startContext.isForceRepositories();
4✔
806
  }
807

808
  @Override
809
  public boolean isOfflineMode() {
810

811
    return this.startContext.isOfflineMode();
4✔
812
  }
813

814
  @Override
815
  public boolean isPrivacyMode() {
816
    return this.startContext.isPrivacyMode();
4✔
817
  }
818

819
  @Override
820
  public boolean isSkipUpdatesMode() {
821

822
    return this.startContext.isSkipUpdatesMode();
4✔
823
  }
824

825
  @Override
826
  public boolean isNoColorsMode() {
827

828
    return this.startContext.isNoColorsMode();
×
829
  }
830

831
  @Override
832
  public NetworkStatus getNetworkStatus() {
833

834
    if (this.networkStatus == null) {
×
835
      this.networkStatus = new NetworkStatusImpl(this);
×
836
    }
837
    return this.networkStatus;
×
838
  }
839

840
  @Override
841
  public Locale getLocale() {
842

843
    Locale locale = this.startContext.getLocale();
4✔
844
    if (locale == null) {
2✔
845
      locale = Locale.getDefault();
2✔
846
    }
847
    return locale;
2✔
848
  }
849

850
  @Override
851
  public DirectoryMerger getWorkspaceMerger() {
852

853
    if (this.workspaceMerger == null) {
3✔
854
      this.workspaceMerger = new DirectoryMerger(this);
6✔
855
    }
856
    return this.workspaceMerger;
3✔
857
  }
858

859
  /**
860
   * @return the default execution directory in which a command process is executed.
861
   */
862
  @Override
863
  public Path getDefaultExecutionDirectory() {
864

865
    return this.defaultExecutionDirectory;
×
866
  }
867

868
  /**
869
   * @param defaultExecutionDirectory new value of {@link #getDefaultExecutionDirectory()}.
870
   */
871
  public void setDefaultExecutionDirectory(Path defaultExecutionDirectory) {
872

873
    if (defaultExecutionDirectory != null) {
×
874
      this.defaultExecutionDirectory = defaultExecutionDirectory;
×
875
    }
876
  }
×
877

878
  @Override
879
  public GitContext getGitContext() {
880

881
    return new GitContextImpl(this);
×
882
  }
883

884
  @Override
885
  public ProcessContext newProcess() {
886

887
    ProcessContext processContext = createProcessContext();
3✔
888
    if (this.defaultExecutionDirectory != null) {
3!
889
      processContext.directory(this.defaultExecutionDirectory);
×
890
    }
891
    return processContext;
2✔
892
  }
893

894
  @Override
895
  public IdeSystem getSystem() {
896

897
    if (this.system == null) {
×
898
      this.system = new IdeSystemImpl();
×
899
    }
900
    return this.system;
×
901
  }
902

903
  /**
904
   * @return a new instance of {@link ProcessContext}.
905
   * @see #newProcess()
906
   */
907
  protected ProcessContext createProcessContext() {
908

909
    return new ProcessContextImpl(this);
×
910
  }
911

912
  @Override
913
  public IdeLogLevel getLogLevelConsole() {
914

915
    return this.startContext.getLogLevelConsole();
×
916
  }
917

918
  @Override
919
  public IdeLogLevel getLogLevelLogger() {
920

921
    return this.startContext.getLogLevelLogger();
×
922
  }
923

924
  @Override
925
  public IdeLogListener getLogListener() {
926

927
    return this.startContext.getLogListener();
×
928
  }
929

930
  @Override
931
  public void logIdeHomeAndRootStatus() {
932
    if (this.ideRoot != null) {
3!
933
      IdeLogLevel.SUCCESS.log(LOG, "IDE_ROOT is set to {}", this.ideRoot);
×
934
    }
935
    if (this.ideHome == null) {
3✔
936
      LOG.warn(getMessageNotInsideIdeProject());
5✔
937
    } else {
938
      IdeLogLevel.SUCCESS.log(LOG, "IDE_HOME is set to {}", this.ideHome);
11✔
939
    }
940
  }
1✔
941

942
  @Override
943
  public String formatArgument(Object argument) {
944

945
    if (argument == null) {
2✔
946
      return null;
2✔
947
    }
948
    String result = argument.toString();
3✔
949
    if (isPrivacyMode()) {
3✔
950
      if ((this.ideRoot != null) && this.privacyMap.isEmpty()) {
3!
951
        initializePrivacyMap(this.userHome, "~");
×
952
        String projectName = getProjectName();
×
953
        if (!projectName.isEmpty()) {
×
954
          this.privacyMap.put(projectName, "project");
×
955
        }
956
      }
957
      for (Entry<String, String> entry : this.privacyMap.entrySet()) {
8!
958
        result = result.replace(entry.getKey(), entry.getValue());
×
959
      }
×
960
      result = PrivacyUtil.removeSensitivePathInformation(result);
3✔
961
    }
962
    return result;
2✔
963
  }
964

965
  /**
966
   * @param path the sensitive {@link Path} to
967
   * @param replacement the replacement to mask the {@link Path} in log output.
968
   */
969
  protected void initializePrivacyMap(Path path, String replacement) {
970

971
    if (path == null) {
×
972
      return;
×
973
    }
974
    if (this.systemInfo.isWindows()) {
×
975
      this.privacyMap.put(WindowsPathSyntax.WINDOWS.format(path), replacement);
×
976
      this.privacyMap.put(WindowsPathSyntax.MSYS.format(path), replacement);
×
977
    } else {
978
      this.privacyMap.put(path.toString(), replacement);
×
979
    }
980
  }
×
981

982
  /**
983
   * Resets the privacy map in case fundamental values have changed.
984
   */
985
  private void resetPrivacyMap() {
986

987
    this.privacyMap.clear();
3✔
988
  }
1✔
989

990

991
  @Override
992
  public String askForInput(String message, String defaultValue) {
993

994
    while (true) {
995
      if (!message.isBlank()) {
3!
996
        IdeLogLevel.INTERACTION.log(LOG, message);
4✔
997
      }
998
      if (isBatchMode()) {
3!
999
        if (isForceMode()) {
×
1000
          return defaultValue;
×
1001
        } else {
1002
          throw new CliAbortException();
×
1003
        }
1004
      }
1005
      String input = readLine().trim();
4✔
1006
      if (!input.isEmpty()) {
3!
1007
        return input;
2✔
1008
      } else {
1009
        if (defaultValue != null) {
×
1010
          return defaultValue;
×
1011
        }
1012
      }
1013
    }
×
1014
  }
1015

1016
  @Override
1017
  public <O> O question(O[] options, String question, Object... args) {
1018

1019
    assert (options.length > 0);
4!
1020
    IdeLogLevel.INTERACTION.log(LOG, question, args);
5✔
1021
    return displayOptionsAndGetAnswer(options);
4✔
1022
  }
1023

1024
  private <O> O displayOptionsAndGetAnswer(O[] options) {
1025
    Map<String, O> mapping = new HashMap<>(options.length);
6✔
1026
    int i = 0;
2✔
1027
    for (O option : options) {
16✔
1028
      i++;
1✔
1029
      String title = "" + option;
4✔
1030
      String key = computeOptionKey(title);
3✔
1031
      addMapping(mapping, key, option);
4✔
1032
      String numericKey = Integer.toString(i);
3✔
1033
      if (numericKey.equals(key)) {
4!
1034
        LOG.trace("Options should not be numeric: {}", key);
×
1035
      } else {
1036
        addMapping(mapping, numericKey, option);
4✔
1037
      }
1038
      IdeLogLevel.INTERACTION.log(LOG, "Option {}: {}", numericKey, title);
14✔
1039
    }
1040
    if (options.length == 1) {
4✔
1041
      mapping.put("", options[0]);
7✔
1042
    }
1043
    O option = null;
2✔
1044
    if (isBatchMode()) {
3!
1045
      if (isForceMode()) {
×
1046
        option = options[0];
×
1047
        IdeLogLevel.INTERACTION.log(LOG, "" + option);
×
1048
      }
1049
    } else {
1050
      while (option == null) {
2✔
1051
        String answer = readLine();
3✔
1052
        option = mapping.get(answer);
4✔
1053
        if (option == null) {
2!
1054
          LOG.warn("Invalid answer: '{}' - please try again.", answer);
×
1055
        }
1056
      }
1✔
1057
    }
1058
    return option;
2✔
1059
  }
1060

1061
  private static String computeOptionKey(String option) {
1062
    String key = option;
2✔
1063
    int index = -1;
2✔
1064
    for (char c : OPTION_DETAILS_START.toCharArray()) {
17✔
1065
      int currentIndex = key.indexOf(c);
4✔
1066
      if (currentIndex != -1) {
3✔
1067
        if ((index == -1) || (currentIndex < index)) {
3!
1068
          index = currentIndex;
2✔
1069
        }
1070
      }
1071
    }
1072
    if (index > 0) {
2✔
1073
      key = key.substring(0, index).trim();
6✔
1074
    }
1075
    return key;
2✔
1076
  }
1077

1078
  /**
1079
   * @return the input from the end-user (e.g. read from the console).
1080
   */
1081
  protected abstract String readLine();
1082

1083
  private static <O> void addMapping(Map<String, O> mapping, String key, O option) {
1084

1085
    O duplicate = mapping.put(key, option);
5✔
1086
    if (duplicate != null) {
2!
1087
      throw new IllegalArgumentException("Duplicated option " + key);
×
1088
    }
1089
  }
1✔
1090

1091
  @Override
1092
  public Step getCurrentStep() {
1093

1094
    return this.currentStep;
×
1095
  }
1096

1097
  @Override
1098
  public StepImpl newStep(boolean silent, String name, Object... parameters) {
1099

1100
    this.currentStep = new StepImpl(this, this.currentStep, name, silent, parameters);
11✔
1101
    return this.currentStep;
3✔
1102
  }
1103

1104
  /**
1105
   * Internal method to end the running {@link Step}.
1106
   *
1107
   * @param step the current {@link Step} to end.
1108
   */
1109
  public void endStep(StepImpl step) {
1110

1111
    if (step == this.currentStep) {
4!
1112
      this.currentStep = this.currentStep.getParent();
6✔
1113
    } else {
1114
      String currentStepName = "null";
×
1115
      if (this.currentStep != null) {
×
1116
        currentStepName = this.currentStep.getName();
×
1117
      }
1118
      LOG.warn("endStep called with wrong step '{}' but expected '{}'", step.getName(), currentStepName);
×
1119
    }
1120
  }
1✔
1121

1122
  /**
1123
   * Finds the matching {@link Commandlet} to run, applies {@link CliArguments} to its {@link Commandlet#getProperties() properties} and will execute it.
1124
   *
1125
   * @param arguments the {@link CliArgument}.
1126
   * @return the return code of the execution.
1127
   */
1128
  public int run(CliArguments arguments) {
1129

1130
    CliArgument current = arguments.current();
3✔
1131
    assert (this.currentStep == null);
4!
1132
    boolean supressStepSuccess = false;
2✔
1133
    StepImpl step = newStep(true, "ide", (Object[]) current.asArray());
8✔
1134
    Iterator<Commandlet> commandletIterator = this.commandletManager.findCommandlet(arguments, null);
6✔
1135
    Commandlet cmd = null;
2✔
1136
    ValidationResult result = null;
2✔
1137
    try {
1138
      while (commandletIterator.hasNext()) {
3✔
1139
        cmd = commandletIterator.next();
4✔
1140
        result = applyAndRun(arguments.copy(), cmd);
6✔
1141
        if (result.isValid()) {
3✔
1142
          supressStepSuccess = cmd.isSuppressStepSuccess();
3✔
1143
          step.success();
2✔
1144
          return ProcessResult.SUCCESS;
4✔
1145
        }
1146
      }
1147
      activateLogging(cmd);
3✔
1148
      verifyIdeMinVersion(false);
3✔
1149

1150
      if (cmd != null && result instanceof ValidationState res) {
8!
1151
        final CliArgument arg = res.getCliArgument();
3✔
1152
        if (arg != null) {
2!
1153
          if (arg.getValue() != null) {
3✔
1154
            // --flag=value with an invalid value: reconstruct the error message here to control order
1155
            String exMsg = res.getParseExceptionMessage();
3✔
1156
            if (exMsg != null) {
2!
1157
              step.error(Property.INVALID_ARGUMENT + ": {}", arg.getValue(), arg.getKey(), cmd.getName(), exMsg);
×
1158
            } else {
1159
              step.error(Property.INVALID_ARGUMENT, arg.getValue(), arg.getKey(), cmd.getName());
20✔
1160
            }
1161
            String hint = res.getParseHint();
3✔
1162
            if (hint != null) {
2!
1163
              LOG.error(Property.INVALID_ARGUMENT_HELP_MULTIPLE, hint);
4✔
1164
            }
1165
          } else {
1✔
1166
            // Unknown option flag or positional value with invalid content
1167
            step.error("Option {} not found for commandlet {}.", arg.get(), cmd.getName());
15✔
1168
            String hint = res.getParseHint();
3✔
1169
            if (hint != null) {
2!
1170
              LOG.error(Property.INVALID_ARGUMENT_HELP_MULTIPLE, hint);
4✔
1171
            }
1172
          }
1173
          IdeLogLevel.INTERACTION.log(LOG, "To see the available options and arguments call the following command:\n"
9✔
1174
              + "ide {} help", cmd.getName());
2✔
1175
          return 1;
4✔
1176
        }
1177
      }
1178
      if (result != null && (!(result instanceof ValidationState) || ((ValidationState) result).getCliArgument() == null) ) {
2!
1179
        LOG.error(result.getErrorMessage());
×
1180
        step.error("Invalid arguments: {}", current.getArgs());
×
1181
        IdeLogLevel.INTERACTION.log(LOG, "For additional details run ide help {}", cmd == null ? "" : cmd.getName());
×
1182
      }
1183
      return 1;
4✔
1184
    } catch (Throwable t) {
1✔
1185
      activateLogging(cmd);
3✔
1186
      step.error(t, true);
4✔
1187
      if (this.logfile != null) {
3!
1188
        System.err.println("Logfile can be found at " + this.logfile); // do not use logger
×
1189
      }
1190
      throw t;
2✔
1191
    } finally {
1192
      step.close();
2✔
1193
      assert (this.currentStep == null);
4!
1194
      step.logSummary(supressStepSuccess);
3✔
1195
    }
1196
  }
1197

1198
  /**
1199
   * Ensure the logging system is initialized.
1200
   */
1201
  private void activateLogging(Commandlet cmd) {
1202

1203
    configureJavaUtilLogging(cmd);
3✔
1204
    this.startContext.activateLogging();
3✔
1205
  }
1✔
1206

1207
  /**
1208
   * Configures the logging system (JUL).
1209
   *
1210
   * @param cmd the {@link Commandlet} to be called. May be {@code null}.
1211
   */
1212
  public void configureJavaUtilLogging(Commandlet cmd) {
1213

1214
    if (this.julConfigured) {
3✔
1215
      return;
1✔
1216
    }
1217
    boolean writeLogfile = isWriteLogfile(cmd);
4✔
1218
    this.startContext.setWriteLogfile(writeLogfile);
4✔
1219
    Properties properties = createJavaUtilLoggingProperties(writeLogfile, cmd);
5✔
1220
    try {
1221
      ByteArrayOutputStream out = new ByteArrayOutputStream(512);
5✔
1222
      properties.store(out, null);
4✔
1223
      out.flush();
2✔
1224
      ByteArrayInputStream in = new ByteArrayInputStream(out.toByteArray());
6✔
1225
      LogManager.getLogManager().readConfiguration(in);
3✔
1226
      this.julConfigured = true;
3✔
1227
      this.startContext.activateLogging();
3✔
1228
    } catch (IOException e) {
×
1229
      LOG.error("Failed to configure logging: {}", e.toString(), e);
×
1230
    }
1✔
1231
  }
1✔
1232

1233
  protected boolean isWriteLogfile(Commandlet cmd) {
1234
    if ((cmd == null) || !cmd.isWriteLogFile()) {
×
1235
      return false;
×
1236
    }
1237
    Boolean writeLogfile = IdeVariables.IDE_WRITE_LOGFILE.get(this);
×
1238
    return Boolean.TRUE.equals(writeLogfile);
×
1239
  }
1240

1241
  private Properties createJavaUtilLoggingProperties(boolean writeLogfile, Commandlet cmd) {
1242

1243
    Path idePath = getIdePath();
3✔
1244
    if (writeLogfile && (idePath == null)) {
2!
1245
      writeLogfile = false;
×
1246
      LOG.error("Cannot enable log-file since IDE_ROOT is undefined.");
×
1247
    }
1248
    Properties properties = new Properties();
4✔
1249
    // prevent 3rd party (e.g. java.lang.ProcessBuilder) logging into our console via JUL
1250
    // see JulLogLevel for the trick we did to workaround JUL flaws
1251
    properties.setProperty(".level", "SEVERE");
5✔
1252
    if (writeLogfile) {
2!
1253
      this.startContext.setLogLevelLogger(IdeLogLevel.TRACE);
×
1254
      String fileHandlerName = FileHandler.class.getName();
×
1255
      properties.setProperty("handlers", JulConsoleHandler.class.getName() + "," + fileHandlerName);
×
1256
      properties.setProperty(fileHandlerName + ".formatter", SimpleFormatter.class.getName());
×
1257
      properties.setProperty(fileHandlerName + ".encoding", "UTF-8");
×
1258
      this.logfile = createLogfilePath(idePath, cmd);
×
1259
      getFileAccess().mkdirs(this.logfile.getParent());
×
1260
      properties.setProperty(fileHandlerName + ".pattern", this.logfile.toString());
×
1261
    } else {
×
1262
      properties.setProperty("handlers", JulConsoleHandler.class.getName());
6✔
1263
    }
1264
    properties.setProperty(SimpleFormatter.class.getName() + ".format", "%1$tY-%1$tm-%1$td %1$tH:%1$tM:%1$tS.%1$tL [%4$s] [%3$s] %5$s%6$s%n");
7✔
1265
    return properties;
2✔
1266
  }
1267

1268
  private Path createLogfilePath(Path idePath, Commandlet cmd) {
1269
    LocalDateTime now = LocalDateTime.now();
×
1270
    Path logsPath = idePath.resolve(FOLDER_LOGS).resolve(DateTimeUtil.formatDate(now, true));
×
1271
    StringBuilder sb = new StringBuilder(32);
×
1272
    if (this.ideHome == null || ((cmd != null) && !cmd.isIdeHomeRequired())) {
×
1273
      sb.append("_ide-");
×
1274
    } else {
1275
      sb.append(this.ideHome.getFileName().toString());
×
1276
      sb.append('-');
×
1277
    }
1278
    sb.append("ide-");
×
1279
    if (cmd != null) {
×
1280
      sb.append(cmd.getName());
×
1281
      sb.append('-');
×
1282
    }
1283
    sb.append(DateTimeUtil.formatTime(now));
×
1284
    sb.append(".log");
×
1285
    return logsPath.resolve(sb.toString());
×
1286
  }
1287

1288
  @Override
1289
  public void runWithoutLogging(Runnable lambda, IdeLogLevel threshold) {
1290

1291
    this.startContext.deactivateLogging(threshold);
4✔
1292
    lambda.run();
2✔
1293
    this.startContext.activateLogging();
3✔
1294
  }
1✔
1295

1296
  /**
1297
   * @param cmd the potential {@link Commandlet} to {@link #apply(CliArguments, Commandlet) apply} and {@link Commandlet#run() run}.
1298
   * @return {@code true} if the given {@link Commandlet} matched and did {@link Commandlet#run() run} successfully, {@code false} otherwise (the
1299
   *     {@link Commandlet} did not match and we have to try a different candidate).
1300
   */
1301
  private ValidationResult applyAndRun(CliArguments arguments, Commandlet cmd) {
1302

1303
    IdeLogLevel previousLogLevel = null;
2✔
1304
    cmd.reset();
2✔
1305
    ValidationResult result = apply(arguments, cmd);
5✔
1306
    if (result.isValid()) {
3✔
1307
      result = cmd.validate();
3✔
1308
    }
1309
    if (result.isValid()) {
3✔
1310
      LOG.debug("Running commandlet {}", cmd);
4✔
1311
      if (cmd.isIdeHomeRequired() && (this.ideHome == null)) {
6!
1312
        throw new CliException(getMessageNotInsideIdeProject(), ProcessResult.NO_IDE_HOME);
×
1313
      } else if (cmd.isIdeRootRequired() && (this.ideRoot == null)) {
6!
1314
        throw new CliException(getMessageIdeRootNotFound(), ProcessResult.NO_IDE_ROOT);
7✔
1315
      }
1316
      try {
1317
        if (cmd.isProcessableOutput()) {
3!
1318
          if (!LOG.isDebugEnabled()) {
×
1319
            // unless --debug or --trace was supplied, processable output commandlets will disable all log-levels except INFO to prevent other logs interfere
1320
            previousLogLevel = this.startContext.setLogLevelConsole(IdeLogLevel.PROCESSABLE);
×
1321
          }
1322
        } else {
1323
          if (cmd.isIdeHomeRequired()) {
3!
1324
            LOG.debug(getMessageIdeHomeFound());
4✔
1325
          }
1326
          Path settingsRepository = getSettingsGitRepository();
3✔
1327
          if (settingsRepository != null) {
2!
1328
            if (getGitContext().isRepositoryUpdateAvailable(settingsRepository, getSettingsCommitIdPath()) || (
×
1329
                getGitContext().fetchIfNeeded(settingsRepository) && getGitContext().isRepositoryUpdateAvailable(
×
1330
                    settingsRepository, getSettingsCommitIdPath()))) {
×
1331

1332
              // Inform the user that an update is available. The update message is suppressed if we are already running the update
1333
              String msg = determineSettingsUpdateMessage(cmd);
×
1334
              if (msg != null) {
×
1335
                IdeLogLevel.INTERACTION.log(LOG, msg);
×
1336
              }
1337
            }
1338
          }
1339
        }
1340
        boolean success = ensureLicenseAgreement(cmd);
4✔
1341
        if (!success) {
2!
1342
          return ValidationResultValid.get();
×
1343
        }
1344
        cmd.run();
2✔
1345
      } finally {
1346
        if (previousLogLevel != null) {
2!
1347
          this.startContext.setLogLevelConsole(previousLogLevel);
×
1348
        }
1349
      }
1✔
1350
    } else {
1351
      LOG.trace("Commandlet did not match");
3✔
1352
    }
1353
    return result;
2✔
1354
  }
1355

1356

1357
  /**
1358
   * When an update is available for the settings repository, we log a message to the console, reminding the user to run {@code ide update}.
1359
   * This method determines the correct message to log, depending on whether the settings repository is a symlink/junction, or not.
1360
   * Should the user already be running the appropriate {@code ide update} command, the message is suppressed to avoid confusion.
1361
   *
1362
   * @param cmd the {@link Commandlet}.
1363
   * @return {@code msg} to log to the console. {@code null} if the message is suppressed.
1364
   */
1365
  private String determineSettingsUpdateMessage(Commandlet cmd) {
1366
    if (isSettingsRepositorySymlinkOrJunction()) {
×
1367
      if ((cmd instanceof UpdateCommandlet) && isForceMode()) {
×
1368
        return null;
×
1369
      }
1370
      return "Updates are available for the settings repository. Please pull the latest changes by yourself or by calling \"ide -f update\" to apply them.";
×
1371
    } else {
1372
      if (cmd instanceof UpdateCommandlet) {
×
1373
        return null;
×
1374
      }
1375
      return "Updates are available for the settings repository. If you want to apply the latest changes, call \"ide update\"";
×
1376
    }
1377
  }
1378

1379
  private boolean ensureLicenseAgreement(Commandlet cmd) {
1380

1381
    if (isTest()) {
3!
1382
      return true; // ignore for tests
2✔
1383
    }
1384
    getFileAccess().mkdirs(this.userHomeIde);
×
1385
    Path licenseAgreement = this.userHomeIde.resolve(FILE_LICENSE_AGREEMENT);
×
1386
    if (Files.isRegularFile(licenseAgreement)) {
×
1387
      return true; // success, license already accepted
×
1388
    }
1389
    if (cmd instanceof EnvironmentCommandlet) {
×
1390
      // if the license was not accepted, "$(ideasy env --bash)" that is written into a variable prevents the user from seeing the question he is asked
1391
      // in such situation the user could not open a bash terminal anymore and gets blocked what would really annoy the user so we exit here without doing or
1392
      // printing anything anymore in such case.
1393
      return false;
×
1394
    }
1395
    activateLogging(cmd);
×
1396
    IdeLogLevel oldLogLevel = this.startContext.getLogLevelConsole();
×
1397
    IdeLogLevel newLogLevel = oldLogLevel;
×
1398
    if (oldLogLevel.ordinal() > IdeLogLevel.INFO.ordinal()) {
×
1399
      newLogLevel = IdeLogLevel.INFO;
×
1400
      this.startContext.setLogLevelConsole(newLogLevel);
×
1401
    }
1402
    StringBuilder sb = new StringBuilder(1180);
×
1403
    sb.append(LOGO).append("""
×
1404
        Welcome to IDEasy!
1405
        This product (with its included 3rd party components) is open-source software and can be used free (also commercially).
1406
        It supports automatic download and installation of arbitrary 3rd party tools.
1407
        By default only open-source 3rd party tools are used (downloaded, installed, executed).
1408
        But if explicitly configured, also commercial software that requires an additional license may be used.
1409
        This happens e.g. if you configure "ultimate" edition of IntelliJ or "docker" edition of Docker (Docker Desktop).
1410
        You are solely responsible for all risks implied by using this software.
1411
        Before using IDEasy you need to read and accept the license agreement with all involved licenses.
1412
        You will be able to find it online under the following URL:
1413
        """).append(LICENSE_URL);
×
1414
    if (this.ideRoot != null) {
×
1415
      sb.append("\n\nAlso it is included in the documentation that you can find here:\n").
×
1416
          append(getIdePath().resolve("IDEasy.pdf").toString()).append("\n");
×
1417
    }
1418
    LOG.info(sb.toString());
×
1419
    askToContinue("Do you accept these terms of use and all license agreements?");
×
1420

1421
    sb.setLength(0);
×
1422
    LocalDateTime now = LocalDateTime.now();
×
1423
    sb.append("On ").append(DateTimeUtil.formatDate(now, false)).append(" at ").append(DateTimeUtil.formatTime(now))
×
1424
        .append(" you accepted the IDEasy license.\n").append(LICENSE_URL);
×
1425
    try {
1426
      Files.writeString(licenseAgreement, sb);
×
1427
    } catch (Exception e) {
×
1428
      throw new RuntimeException("Failed to save license agreement!", e);
×
1429
    }
×
1430
    if (oldLogLevel != newLogLevel) {
×
1431
      this.startContext.setLogLevelConsole(oldLogLevel);
×
1432
    }
1433
    return true;
×
1434
  }
1435

1436
  @Override
1437
  public void verifyIdeMinVersion(boolean throwException) {
1438
    VersionIdentifier minVersion = IDE_MIN_VERSION.get(this);
5✔
1439
    if (minVersion == null) {
2✔
1440
      return;
1✔
1441
    }
1442
    VersionIdentifier versionIdentifier = IdeVersion.getVersionIdentifier();
2✔
1443
    if (versionIdentifier.compareVersion(minVersion).isLess() && !IdeVersion.isUndefined()) {
7!
1444
      String message = String.format("Your version of IDEasy is currently %s\n"
13✔
1445
          + "However, this is too old as your project requires at latest version %s\n"
1446
          + "Please run the following command to update to the latest version of IDEasy and fix the problem:\n"
1447
          + "ide upgrade", versionIdentifier, minVersion);
1448
      if (throwException) {
2✔
1449
        throw new CliException(message);
5✔
1450
      } else {
1451
        LOG.warn(message);
3✔
1452
      }
1453
    }
1454
  }
1✔
1455

1456
  /**
1457
   * @param arguments the {@link CliArguments#ofCompletion(String...) completion arguments}.
1458
   * @param includeContextOptions to include the options of {@link ContextCommandlet}.
1459
   * @return the {@link List} of {@link CompletionCandidate}s to suggest.
1460
   */
1461
  public List<CompletionCandidate> complete(CliArguments arguments, boolean includeContextOptions) {
1462

1463
    CompletionCandidateCollector collector = new CompletionCandidateCollectorDefault(this);
5✔
1464
    if (arguments.current().isStart()) {
4✔
1465
      arguments.next();
3✔
1466
    }
1467
    if (includeContextOptions) {
2✔
1468
      ContextCommandlet cc = new ContextCommandlet();
4✔
1469
      for (Property<?> property : cc.getProperties()) {
11✔
1470
        assert (property.isOption());
4!
1471
        property.apply(arguments, this, cc, collector);
7✔
1472
      }
1✔
1473
    }
1474
    Iterator<Commandlet> commandletIterator = this.commandletManager.findCommandlet(arguments, collector);
6✔
1475
    CliArgument current = arguments.current();
3✔
1476
    if (current.isCompletion() && current.isCombinedShortOption()) {
6✔
1477
      collector.add(current.get(), null, null, null);
7✔
1478
    }
1479
    arguments.next();
3✔
1480
    while (commandletIterator.hasNext()) {
3✔
1481
      Commandlet cmd = commandletIterator.next();
4✔
1482
      if (!arguments.current().isEnd()) {
4✔
1483
        completeCommandlet(arguments.copy(), cmd, collector);
6✔
1484
      }
1485
    }
1✔
1486
    return collector.getSortedCandidates();
3✔
1487
  }
1488

1489
  private void completeCommandlet(CliArguments arguments, Commandlet cmd, CompletionCandidateCollector collector) {
1490

1491
    LOG.trace("Trying to match arguments for auto-completion for commandlet {}", cmd.getName());
5✔
1492
    Iterator<Property<?>> valueIterator = cmd.getValues().iterator();
4✔
1493
    valueIterator.next(); // skip first property since this is the keyword property that already matched to find the commandlet
3✔
1494
    List<Property<?>> properties = cmd.getProperties();
3✔
1495
    // we are creating our own list of options and remove them when matched to avoid duplicate suggestions
1496
    List<Property<?>> optionProperties = new ArrayList<>(properties.size());
6✔
1497
    for (Property<?> property : properties) {
10✔
1498
      if (property.isOption()) {
3✔
1499
        optionProperties.add(property);
4✔
1500
      }
1501
    }
1✔
1502
    CliArgument currentArgument = arguments.current();
3✔
1503
    while (!currentArgument.isEnd()) {
3✔
1504
      LOG.trace("Trying to match argument '{}'", currentArgument);
4✔
1505
      if (currentArgument.isOption() && !arguments.isEndOptions()) {
6!
1506
        if (currentArgument.isCompletion()) {
3✔
1507
          Iterator<Property<?>> optionIterator = optionProperties.iterator();
3✔
1508
          while (optionIterator.hasNext()) {
3✔
1509
            Property<?> option = optionIterator.next();
4✔
1510
            boolean success = option.apply(arguments, this, cmd, collector);
7✔
1511
            if (success) {
2✔
1512
              optionIterator.remove();
2✔
1513
              arguments.next();
3✔
1514
            }
1515
          }
1✔
1516
        } else {
1✔
1517
          Property<?> option = cmd.getOption(currentArgument.get());
5✔
1518
          if (option != null) {
2✔
1519
            arguments.next();
3✔
1520
            boolean removed = optionProperties.remove(option);
4✔
1521
            if (!removed) {
2!
1522
              option = null;
×
1523
            }
1524
          }
1525
          if (option == null) {
2✔
1526
            LOG.trace("No such option was found.");
3✔
1527
            return;
1✔
1528
          }
1529
        }
1✔
1530
      } else {
1531
        if (valueIterator.hasNext()) {
3✔
1532
          Property<?> valueProperty = valueIterator.next();
4✔
1533
          boolean success = valueProperty.apply(arguments, this, cmd, collector);
7✔
1534
          if (!success) {
2✔
1535
            LOG.trace("Completion cannot match any further.");
3✔
1536
            return;
1✔
1537
          }
1538
        } else {
1✔
1539
          LOG.trace("No value left for completion.");
3✔
1540
          return;
1✔
1541
        }
1542
      }
1543
      currentArgument = arguments.current();
4✔
1544
    }
1545
  }
1✔
1546

1547
  /**
1548
   * @param arguments the {@link CliArguments} to apply. Will be {@link CliArguments#next() consumed} as they are matched. Consider passing a
1549
   *     {@link CliArguments#copy() copy} as needed.
1550
   * @param cmd the potential {@link Commandlet} to match.
1551
   * @return the {@link ValidationResult} telling if the {@link CliArguments} can be applied successfully or if validation errors ocurred.
1552
   */
1553
  public ValidationResult apply(CliArguments arguments, Commandlet cmd) {
1554

1555
    LOG.trace("Trying to match arguments to commandlet {}", cmd.getName());
5✔
1556
    CliArgument currentArgument = arguments.current();
3✔
1557
    Iterator<Property<?>> propertyIterator = cmd.getValues().iterator();
4✔
1558
    Property<?> property = null;
2✔
1559
    if (propertyIterator.hasNext()) {
3!
1560
      property = propertyIterator.next();
4✔
1561
    }
1562
    while (!currentArgument.isEnd()) {
3✔
1563
      LOG.trace("Trying to match argument '{}'", currentArgument);
4✔
1564
      Property<?> currentProperty = property;
2✔
1565
      if (!arguments.isEndOptions()) {
3!
1566
        Property<?> option = cmd.getOption(currentArgument.getKey());
5✔
1567
        if (option != null) {
2✔
1568
          currentProperty = option;
2✔
1569
        }
1570
      }
1571
      if (currentProperty == null) {
2!
1572
        LOG.trace("No option or next value found");
×
1573
        ValidationState state = new ValidationState(null);
×
1574
        state.setCliArgument(currentArgument);
×
1575
        state.addErrorMessage("No matching property found");
×
1576
        return state;
×
1577
      }
1578
      LOG.trace("Next property candidate to match argument is {}", currentProperty);
4✔
1579
      if (currentProperty == property) {
3✔
1580
        if (!property.isMultiValued()) {
3✔
1581
          if (propertyIterator.hasNext()) {
3✔
1582
            property = propertyIterator.next();
5✔
1583
          } else {
1584
            property = null;
2✔
1585
          }
1586
        }
1587
        if ((property != null) && property.isValue() && property.isMultiValued()) {
8!
1588
          arguments.stopSplitShortOptions();
2✔
1589
        }
1590
      }
1591
      boolean matches = currentProperty.apply(arguments, this, cmd, null);
7✔
1592
      if (!matches) {
2✔
1593
        ValidationState state = new ValidationState(null);
5✔
1594
        state.addErrorMessage("No matching property found");
3✔
1595
        state.setCliArgument(currentArgument);
3✔
1596
        state.setParseHint(currentProperty.getAndClearLastParseHint());
4✔
1597
        state.setParseExceptionMessage(currentProperty.getAndClearLastParseExceptionMessage());
4✔
1598
        return state;
2✔
1599
      }
1600
      currentArgument = arguments.current();
3✔
1601
    }
1✔
1602
    return ValidationResultValid.get();
2✔
1603
  }
1604

1605
  @Override
1606
  public Path findBash() {
1607
    if (this.bash != null) {
3✔
1608
      return this.bash;
3✔
1609
    }
1610
    Path bashPath = findBashOnBashPath();
3✔
1611
    if (bashPath == null) {
2✔
1612
      bashPath = findBashInPath();
3✔
1613
      if (bashPath == null && (getSystemInfo().isWindows() || SystemInfoImpl.INSTANCE.isWindows())) {
6!
1614
        bashPath = findBashOnWindowsDefaultGitPath();
3✔
1615
        if (bashPath == null) {
2!
1616
          bashPath = findBashInWindowsRegistry();
3✔
1617
        }
1618
      }
1619
    }
1620
    if (bashPath == null) {
2✔
1621
      LOG.error("No bash executable could be found on your system.");
4✔
1622
    } else {
1623
      this.bash = bashPath;
3✔
1624
    }
1625
    return bashPath;
2✔
1626
  }
1627

1628
  private Path findBashOnBashPath() {
1629
    LOG.trace("Trying to find BASH_PATH environment variable.");
3✔
1630
    Path bash;
1631
    String bashPathVariableName = IdeVariables.BASH_PATH.getName();
3✔
1632
    String bashVariable = getVariables().get(bashPathVariableName);
5✔
1633
    if (bashVariable != null) {
2✔
1634
      bash = Path.of(bashVariable);
5✔
1635
      if (Files.exists(bash)) {
5✔
1636
        LOG.debug("{} environment variable was found and points to: {}", bashPathVariableName, bash);
5✔
1637
        return bash;
2✔
1638
      } else {
1639
        LOG.error("The environment variable {} points to a non existing file: {}", bashPathVariableName, bash);
5✔
1640
        return null;
2✔
1641
      }
1642
    } else {
1643
      LOG.debug("{} environment variable was not found", bashPathVariableName);
4✔
1644
      return null;
2✔
1645
    }
1646
  }
1647

1648
  /**
1649
   * @param path the path to check.
1650
   * @param toIgnore the String sequence which needs to be checked and ignored.
1651
   * @return {@code true} if the sequence to ignore was not found, {@code false} if the path contained the sequence to ignore.
1652
   */
1653
  private boolean checkPathToIgnoreLowercase(Path path, String toIgnore) {
1654
    String s = path.toAbsolutePath().toString().toLowerCase(Locale.ROOT);
6✔
1655
    return !s.contains(toIgnore);
7!
1656
  }
1657

1658
  /**
1659
   * Tries to find the bash.exe within the PATH environment variable.
1660
   *
1661
   * @return Path to bash.exe if found in PATH environment variable, {@code null} if bash.exe was not found.
1662
   */
1663
  private Path findBashInPath() {
1664
    LOG.trace("Trying to find bash in PATH environment variable.");
3✔
1665
    Path bash;
1666
    String pathVariableName = IdeVariables.PATH.getName();
3✔
1667
    if (pathVariableName != null) {
2!
1668
      Path plainBash = Path.of(BASH);
5✔
1669
      Predicate<Path> pathsToIgnore = p -> checkPathToIgnoreLowercase(p, "\\appdata\\local\\microsoft\\windowsapps") && checkPathToIgnoreLowercase(p,
16!
1670
          "\\windows\\system32");
1671
      Path bashPath = getPath().findBinary(plainBash, pathsToIgnore);
6✔
1672
      bash = bashPath.toAbsolutePath();
3✔
1673
      if (bashPath.equals(plainBash)) {
4✔
1674
        LOG.warn("No usable bash executable was found in your PATH environment variable!");
3✔
1675
        bash = null;
3✔
1676
      } else {
1677
        if (Files.exists(bashPath)) {
5!
1678
          LOG.debug("A proper bash executable was found in your PATH environment variable at: {}", bash);
5✔
1679
        } else {
1680
          bash = null;
×
1681
          LOG.error("A path to a bash executable was found in your PATH environment variable at: {} but the file is not existing.", bash);
×
1682
        }
1683
      }
1684
    } else {
1✔
1685
      bash = null;
×
1686
      // this should never happen...
1687
      LOG.error("PATH environment variable was not found");
×
1688
    }
1689
    return bash;
2✔
1690
  }
1691

1692
  /**
1693
   * Tries to find the bash.exe within the Windows registry.
1694
   *
1695
   * @return Path to bash.exe if found in registry, {@code null} if bash.exe was found.
1696
   */
1697
  protected Path findBashInWindowsRegistry() {
1698
    LOG.trace("Trying to find bash in Windows registry");
×
1699
    // If not found in the default location, try the registry query
1700
    String[] bashVariants = { "GitForWindows", "Cygwin\\setup" };
×
1701
    String[] registryKeys = { "HKEY_LOCAL_MACHINE", "HKEY_CURRENT_USER" };
×
1702
    for (String bashVariant : bashVariants) {
×
1703
      LOG.trace("Trying to find bash variant: {}", bashVariant);
×
1704
      for (String registryKey : registryKeys) {
×
1705
        LOG.trace("Trying to find bash from registry key: {}", registryKey);
×
1706
        String toolValueName = ("GitForWindows".equals(bashVariant)) ? "InstallPath" : "rootdir";
×
1707
        String registryPath = registryKey + "\\Software\\" + bashVariant;
×
1708

1709
        String path = getWindowsHelper().getRegistryValue(registryPath, toolValueName);
×
1710
        if (path != null) {
×
1711
          Path bashPath = Path.of(path + "\\bin\\bash.exe");
×
1712
          if (Files.exists(bashPath)) {
×
1713
            LOG.debug("Found bash at: {}", bashPath);
×
1714
            return bashPath;
×
1715
          } else {
1716
            LOG.error("Found bash at: {} but it is not pointing to an existing file", bashPath);
×
1717
            return null;
×
1718
          }
1719
        } else {
1720
          LOG.info("No bash executable could be found in the Windows registry.");
×
1721
        }
1722
      }
1723
    }
1724
    // no bash found
1725
    return null;
×
1726
  }
1727

1728
  private Path findBashOnWindowsDefaultGitPath() {
1729
    // Check if Git Bash exists in the default location
1730
    LOG.trace("Trying to find bash on the Windows default git path.");
3✔
1731
    Path defaultPath = Path.of(getDefaultWindowsGitPath());
6✔
1732
    if (!defaultPath.toString().isEmpty() && Files.exists(defaultPath)) {
4!
1733
      LOG.trace("Found default path to git bash on Windows at: {}", getDefaultWindowsGitPath());
×
1734
      return defaultPath;
×
1735
    }
1736
    LOG.debug("No bash was found on the Windows default git path.");
3✔
1737
    return null;
2✔
1738
  }
1739

1740
  @Override
1741
  public WindowsPathSyntax getPathSyntax() {
1742

1743
    return this.pathSyntax;
3✔
1744
  }
1745

1746
  /**
1747
   * @param pathSyntax new value of {@link #getPathSyntax()}.
1748
   */
1749
  public void setPathSyntax(WindowsPathSyntax pathSyntax) {
1750

1751
    this.pathSyntax = pathSyntax;
3✔
1752
  }
1✔
1753

1754
  /**
1755
   * @return the {@link IdeStartContextImpl}.
1756
   */
1757
  public IdeStartContextImpl getStartContext() {
1758

1759
    return startContext;
3✔
1760
  }
1761

1762
  /**
1763
   * @return the {@link WindowsHelper}.
1764
   */
1765
  public final WindowsHelper getWindowsHelper() {
1766

1767
    if (this.windowsHelper == null) {
3✔
1768
      this.windowsHelper = createWindowsHelper();
4✔
1769
    }
1770
    return this.windowsHelper;
3✔
1771
  }
1772

1773
  /**
1774
   * @return the new {@link WindowsHelper} instance.
1775
   */
1776
  protected WindowsHelper createWindowsHelper() {
1777

1778
    return new WindowsHelperImpl(this);
×
1779
  }
1780

1781
  /**
1782
   * Reloads this context and re-initializes the {@link #getVariables() variables}.
1783
   */
1784
  public void reload() {
1785

1786
    this.variables = null;
3✔
1787
    this.customToolRepository = null;
3✔
1788
  }
1✔
1789

1790
  @Override
1791
  public void writeVersionFile(VersionIdentifier version, Path installationPath) {
1792

1793
    assert (Files.isDirectory(installationPath));
6!
1794
    Path versionFile = installationPath.resolve(FILE_SOFTWARE_VERSION);
4✔
1795
    getFileAccess().writeFileContent(version.toString(), versionFile);
6✔
1796
  }
1✔
1797

1798
  /*
1799
   * @param home the IDE_HOME directory.
1800
   * @param workspace the name of the active workspace folder.
1801
   */
1802
  protected static record IdeHomeAndWorkspace(Path home, String workspace) {
9✔
1803

1804
  }
1805

1806
  /**
1807
   * Returns the default git path on Windows. Required to be overwritten in tests.
1808
   *
1809
   * @return default path to git on Windows.
1810
   */
1811
  public String getDefaultWindowsGitPath() {
1812
    return DEFAULT_WINDOWS_GIT_PATH;
×
1813
  }
1814

1815
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc